First pass at uniformed types, specs and docs

- spec public functions (skipping controllers, views, live views and
components)
- use types instead of explanations in docs
- remove redundant docs
- fix typos
This commit is contained in:
Claudio Ortolina
2026-03-06 08:33:11 +00:00
parent 99e30d5fdf
commit 7cf9b4e7f8
81 changed files with 652 additions and 300 deletions
+2
View File
@@ -1,10 +1,12 @@
defmodule BraveSearch do
alias BraveSearch.API
@spec search_images(String.t(), keyword()) :: {:ok, [map()]} | {:error, term()}
def search_images(query, opts \\ []) do
API.search_images(query, opts, config())
end
@spec download_image(String.t()) :: {:ok, binary()} | {:error, :download_failed}
def download_image(url) do
API.download_image(url, config())
end
+4
View File
@@ -5,6 +5,8 @@ defmodule BraveSearch.API do
require Logger
@spec search_images(String.t(), keyword(), BraveSearch.Config.t()) ::
{:ok, [map()]} | {:error, term()}
def search_images(query, opts, config) do
params = [q: query, count: Keyword.get(opts, :count, 20)]
@@ -34,6 +36,8 @@ defmodule BraveSearch.API do
end
end
@spec download_image(String.t(), BraveSearch.Config.t()) ::
{:ok, binary()} | {:error, :download_failed}
def download_image(url, config) do
case Req.new(url: url, max_retries: 1, user_agent: config.user_agent)
|> Req.Request.merge_options(config.req_options)
+2
View File
@@ -1,12 +1,14 @@
defmodule Discogs do
alias Discogs.API
@spec get_artist(integer() | String.t()) :: {:ok, map()} | {:error, term()}
def get_artist(id) do
discogs_config = discogs_config()
API.get_artist(id, discogs_config)
end
@spec get_artist_image(String.t()) :: {:ok, binary()} | {:error, :cover_not_available}
def get_artist_image(url) do
discogs_config = discogs_config()
+3
View File
@@ -5,6 +5,7 @@ defmodule Discogs.API do
require Logger
@spec get_artist(integer() | String.t(), Discogs.Config.t()) :: {:ok, map()} | {:error, term()}
def get_artist(id, config) do
config
|> new_request()
@@ -15,6 +16,8 @@ defmodule Discogs.API do
|> get_request()
end
@spec get_artist_image(String.t(), Discogs.Config.t()) ::
{:ok, binary()} | {:error, :cover_not_available}
def get_artist_image(url, config) do
case Req.new(url: url, max_retries: 1, user_agent: config.user_agent)
|> Req.Request.merge_options(config.req_options)
+13
View File
@@ -2,19 +2,24 @@ defmodule LastFm do
alias LastFm.{API, Feed, Refresh, Scrobble, Track, Worker}
alias MusicLibrary.{BackgroundRepo, Repo}
@spec subscribe_to_feed() :: :ok
def subscribe_to_feed, do: Feed.subscribe()
@spec refresh_scrobbled_tracks() :: :ok | {:error, term()}
def refresh_scrobbled_tracks, do: Refresh.refresh()
@spec get_tracks(keyword()) :: {:ok, [Track.t()]} | {:error, term()}
def get_tracks(opts) do
last_fm_config = last_fm_config()
API.get_recent_tracks(opts, last_fm_config)
end
@spec lowest_scrobbled_at_uts() :: integer() | nil
def lowest_scrobbled_at_uts do
Repo.aggregate(Track, :min, :scrobbled_at_uts)
end
@spec backfill_scrobbled_tracks() :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def backfill_scrobbled_tracks do
to_uts = lowest_scrobbled_at_uts()
@@ -23,6 +28,7 @@ defmodule LastFm do
|> BackgroundRepo.insert()
end
@spec get_artist_info(String.t(), String.t()) :: {:ok, LastFm.Artist.t()} | {:error, term()}
def get_artist_info(musicbrainz_id, name) do
last_fm_config = last_fm_config()
@@ -40,6 +46,8 @@ defmodule LastFm do
end
end
@spec get_similar_artists(String.t(), String.t()) ::
{:ok, [LastFm.Artist.t()]} | {:error, term()}
def get_similar_artists(musicbrainz_id, name) do
last_fm_config = last_fm_config()
@@ -57,6 +65,8 @@ defmodule LastFm do
end
end
@spec get_artist_tags(String.t(), String.t()) ::
{:ok, [{String.t(), integer()}]} | {:error, term()}
def get_artist_tags(musicbrainz_id, name) do
last_fm_config = last_fm_config()
@@ -74,12 +84,14 @@ defmodule LastFm do
end
end
@spec get_session(String.t()) :: {:ok, LastFm.Session.t()} | {:error, term()}
def get_session(token) do
last_fm_config = last_fm_config()
API.get_session(token, last_fm_config)
end
@spec scrobble([Scrobble.t()], String.t()) :: {:ok, map()} | {:error, term()}
def scrobble(scrobbles, session_key) do
last_fm_config = last_fm_config()
@@ -88,6 +100,7 @@ defmodule LastFm do
|> API.scrobble(session_key, last_fm_config)
end
@spec auth_url() :: String.t()
def auth_url do
last_fm_config = last_fm_config()
"https://www.last.fm/api/auth/?api_key=" <> last_fm_config.api_key
+1
View File
@@ -13,6 +13,7 @@ defmodule LastFm.Album do
field :title, :string
end
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(album, attrs) do
album
|> cast(attrs, [:musicbrainz_id, :title])
+10
View File
@@ -4,6 +4,9 @@ defmodule LastFm.API do
require Logger
@type id_or_name :: {:musicbrainz_id, String.t()} | {:name, String.t()}
@spec get_session(String.t(), LastFm.Config.t()) :: {:ok, Session.t()} | {:error, term()}
def get_session(token, config) do
params =
%{
@@ -24,6 +27,7 @@ defmodule LastFm.API do
|> get_request()
end
@spec scrobble([map()], String.t(), LastFm.Config.t()) :: {:ok, map()} | {:error, term()}
def scrobble(tracks, session_key, config) do
params =
%{"api_key" => config.api_key, "method" => "track.scrobble", "sk" => session_key}
@@ -51,6 +55,7 @@ defmodule LastFm.API do
|> post_request()
end
@spec get_recent_tracks(keyword(), LastFm.Config.t()) :: {:ok, [Track.t()]} | {:error, term()}
def get_recent_tracks(opts \\ [], config) do
to_uts = Keyword.get(opts, :to_uts)
limit = Keyword.get(opts, :limit, 100)
@@ -69,6 +74,7 @@ defmodule LastFm.API do
|> get_request()
end
@spec get_artist_info(id_or_name(), LastFm.Config.t()) :: {:ok, Artist.t()} | {:error, term()}
def get_artist_info(id_or_name_option, config) do
params =
config
@@ -83,6 +89,8 @@ defmodule LastFm.API do
|> get_request()
end
@spec get_similar_artists(id_or_name(), LastFm.Config.t()) ::
{:ok, [Artist.t()]} | {:error, term()}
def get_similar_artists(id_or_name_option, config) do
params =
config
@@ -97,6 +105,8 @@ defmodule LastFm.API do
|> get_request()
end
@spec get_artist_tags(id_or_name(), LastFm.Config.t()) ::
{:ok, [{String.t(), integer()}]} | {:error, term()}
def get_artist_tags(id_or_name_option, config) do
params =
config
+24
View File
@@ -1,6 +1,28 @@
defmodule LastFm.API.ErrorResponse do
defstruct [:error, :message]
@type error_atom ::
:invalid_service
| :invalid_method
| :authentication_failed
| :invalid_format
| :invalid_parameters
| :invalid_resource
| :operation_failed
| :invalid_session_key
| :invalid_api_key
| :service_offline
| :invalid_method_signature
| :transient_error
| :suspended_api_key
| :rate_limit_exceeded
@type t :: %__MODULE__{
error: error_atom(),
message: String.t()
}
@spec new(integer(), String.t()) :: t()
def new(error_code, message) do
%__MODULE__{error: map_error(error_code), message: message}
end
@@ -23,6 +45,7 @@ defmodule LastFm.API.ErrorResponse do
@doc """
Returns true if the error is retryable, false otherwise.
"""
@spec retryable_error?(atom()) :: boolean()
def retryable_error?(error)
when error in [:transient_error, :service_offline, :rate_limit_exceeded, :operation_failed],
do: true
@@ -33,6 +56,7 @@ defmodule LastFm.API.ErrorResponse do
Returns the recommended retry delay in milliseconds for retryable errors.
Returns nil for non-retryable errors.
"""
@spec retry_delay(atom()) :: pos_integer() | nil
# 1 minute
def retry_delay(:rate_limit_exceeded), do: 60_000
# 30 seconds
+1
View File
@@ -1,4 +1,5 @@
defmodule LastFm.API.Signature do
@spec generate(map() | keyword(), String.t()) :: String.t()
def generate(params, shared_secret) do
encoded_params =
params
+3
View File
@@ -28,6 +28,7 @@ defmodule LastFm.Artist do
field :image_data_hash, :string
end
@spec from_api_response(map()) :: t()
def from_api_response(api_response) do
%__MODULE__{
musicbrainz_id: api_response["mbid"],
@@ -41,6 +42,7 @@ defmodule LastFm.Artist do
}
end
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(artist, attrs) do
artist
|> cast(attrs, [
@@ -56,6 +58,7 @@ defmodule LastFm.Artist do
|> validate_required([:name])
end
@spec events_url(t()) :: String.t()
def events_url(artist) do
artist.base_url <> "/+events"
end
+1
View File
@@ -1,4 +1,5 @@
defmodule LastFm.Import do
@spec batch(keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
def batch(opts) do
with {:ok, tracks} <- LastFm.get_tracks(opts) do
LastFm.Feed.update(tracks)
+10
View File
@@ -1,6 +1,16 @@
defmodule LastFm.Scrobble do
defstruct [:track, :artist, :timestamp, :album, :album_artist, :mbid]
@type t :: %__MODULE__{
track: String.t(),
artist: String.t(),
timestamp: integer(),
album: String.t() | nil,
album_artist: String.t() | nil,
mbid: String.t() | nil
}
@spec encode(t()) :: map()
def encode(scrobble) do
scrobble
|> Map.from_struct()
+8
View File
@@ -3,6 +3,12 @@ defmodule LastFm.Session do
defstruct [:name, :key, :pro]
@type t :: %__MODULE__{
name: String.t() | nil,
key: String.t() | nil,
pro: boolean() | nil
}
Record.defrecord(
:xmlAttribute,
Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")
@@ -32,6 +38,7 @@ defmodule LastFm.Session do
pro: true
}
"""
@spec parse(String.t()) :: t()
def parse(xml_string) do
doc = scan(xml_string)
@@ -77,6 +84,7 @@ defmodule LastFm.Session do
:xmerl_xpath.string(to_charlist(path), node)
end
@spec text(tuple() | nil) :: String.t() | nil
def text(node), do: node |> xpath(~c"./text()") |> extract_text()
defp extract_text([xmlText(value: value)]), do: List.to_string(value)
+2
View File
@@ -35,6 +35,7 @@ defmodule LastFm.Track do
field :last_fm_data, :map, default: %{}
end
@spec from_api_response([map()]) :: [t()]
def from_api_response(raw_tracks) do
raw_tracks
|> Enum.reject(&now_playing?/1)
@@ -79,6 +80,7 @@ defmodule LastFm.Track do
end
end
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(track, attrs) do
track
|> cast(attrs, [
+2 -2
View File
@@ -1,7 +1,7 @@
defmodule Mix.Tasks.Esbuild.CheckVersion do
@shortdoc "Checks the the current Esbuild version is the latest"
@shortdoc "Checks the current Esbuild version is the latest"
@moduledoc """
Checks the the current esbuild version is the latest.
Checks the current esbuild version is the latest.
Exits with 0 if versions match, 1 if the esbuild needs to be updated.
"""
+17
View File
@@ -1,6 +1,15 @@
defmodule MusicBrainz do
alias MusicBrainz.API
@type search_opts :: [limit: non_neg_integer(), offset: non_neg_integer()]
@spec search_release_group(String.t(), search_opts()) ::
{:ok,
%{
total_count: non_neg_integer(),
release_groups: [MusicBrainz.ReleaseGroupSearchResult.t()]
}}
| {:error, term()}
def search_release_group(query, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
offset = Keyword.get(opts, :offset, 0)
@@ -12,26 +21,34 @@ defmodule MusicBrainz do
)
end
@spec get_release_group(String.t()) :: {:ok, map()} | {:error, term()}
def get_release_group(musicbrainz_id) do
API.get_release_group(musicbrainz_id, music_brainz_config())
end
@spec get_releases(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def get_releases(musicbrainz_id, opts) do
API.get_releases(musicbrainz_id, opts, music_brainz_config())
end
@spec get_release(String.t()) :: {:ok, map()} | {:error, term()}
def get_release(musicbrainz_id) do
API.get_release(musicbrainz_id, music_brainz_config())
end
@spec search_release_by_barcode(String.t()) ::
{:ok, [MusicBrainz.ReleaseSearchResult.t()]} | {:error, term()}
def search_release_by_barcode(barcode) do
API.search_release_by_barcode(barcode, music_brainz_config())
end
@spec get_cover_art({:musicbrainz_id, String.t()} | {:url, String.t()}) ::
{:ok, binary()} | {:error, term()}
def get_cover_art(id_or_url) do
API.get_cover_art(id_or_url, music_brainz_config())
end
@spec get_artist(String.t()) :: {:ok, MusicBrainz.Artist.t()} | {:error, term()}
def get_artist(musicbrainz_id) do
API.get_artist(musicbrainz_id, music_brainz_config())
end
+12
View File
@@ -206,6 +206,7 @@ defmodule MusicBrainz.API do
}
"""
@spec get_release_group(String.t(), MusicBrainz.Config.t()) :: {:ok, map()} | {:error, term()}
def get_release_group(id, config) do
config
|> new_request()
@@ -282,6 +283,7 @@ defmodule MusicBrainz.API do
"title": "Clark (Soundtrack From the Netflix Series)"
}
"""
@spec get_release(String.t(), MusicBrainz.Config.t()) :: {:ok, map()} | {:error, term()}
def get_release(id, config) do
config
|> new_request()
@@ -295,6 +297,8 @@ defmodule MusicBrainz.API do
|> get_request()
end
@spec get_releases(String.t(), keyword(), MusicBrainz.Config.t()) ::
{:ok, map()} | {:error, term()}
def get_releases(release_group_id, opts, config) do
Keyword.validate!(opts, [:limit, :offset])
@@ -314,6 +318,8 @@ defmodule MusicBrainz.API do
|> get_request()
end
@spec search_release_by_barcode(String.t(), MusicBrainz.Config.t()) ::
{:ok, [ReleaseSearchResult.t()]} | {:error, term()}
def search_release_by_barcode(barcode, config) do
config
|> new_request()
@@ -430,6 +436,9 @@ defmodule MusicBrainz.API do
]
}
"""
@spec search_release_group(String.t(), keyword(), MusicBrainz.Config.t()) ::
{:ok, %{total_count: non_neg_integer(), release_groups: [ReleaseGroupSearchResult.t()]}}
| {:error, term()}
def search_release_group(query, opts, config) do
Keyword.validate!(opts, [:limit, :offset])
@@ -451,6 +460,7 @@ defmodule MusicBrainz.API do
|> get_request()
end
@spec get_artist(String.t(), MusicBrainz.Config.t()) :: {:ok, Artist.t()} | {:error, term()}
def get_artist(musicbrainz_id, config) do
config
|> new_request()
@@ -468,6 +478,8 @@ defmodule MusicBrainz.API do
@doc """
Uses the [cover art](https://musicbrainz.org/doc/Cover_Art_Archive/API) endpoint with the release group id to get the cover image.
"""
@spec get_cover_art({:musicbrainz_id, String.t()} | {:url, String.t()}, MusicBrainz.Config.t()) ::
{:ok, binary()} | {:error, :cover_not_available}
def get_cover_art({:musicbrainz_id, musicbrainz_id}, config) do
url = "https://coverartarchive.org/release-group/#{musicbrainz_id}/front"
+13
View File
@@ -2,6 +2,16 @@ defmodule MusicBrainz.Artist do
@enforce_keys [:id, :name, :sort_name]
defstruct [:id, :name, :sort_name, :country, :relations, :musicbrainz_data]
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
sort_name: String.t(),
country: String.t() | nil,
relations: [map()] | nil,
musicbrainz_data: map() | nil
}
@spec from_api_response(map()) :: t()
def from_api_response(r) do
%__MODULE__{
id: r["id"],
@@ -15,6 +25,7 @@ defmodule MusicBrainz.Artist do
# MASSIVE ASSUMPTION: if there's more than one Discogs link,
# take the one with the lowest ID, as it's likely to be the main one.
@spec get_discogs_id(t()) :: integer() | nil
def get_discogs_id(r) do
candidates =
r.relations
@@ -32,6 +43,7 @@ defmodule MusicBrainz.Artist do
end
end
@spec get_wikidata_id(t()) :: String.t() | nil
def get_wikidata_id(r) do
Enum.find_value(r.relations, fn relation ->
if relation.type == "wikidata" do
@@ -40,6 +52,7 @@ defmodule MusicBrainz.Artist do
end)
end
@spec url(String.t()) :: String.t()
def url(id) do
"https://musicbrainz.org/artist/#{id}"
end
+6
View File
@@ -1,6 +1,12 @@
defmodule MusicBrainz.ExternalLink do
defstruct [:name, :url]
@type t :: %__MODULE__{
name: atom(),
url: String.t()
}
@spec external_links(map(), map() | String.t() | nil) :: [t()] | [String.t()]
def external_links(musicbrainz_data, patterns) when is_map(patterns) do
Enum.reduce(patterns, [], fn {name, pattern}, acc ->
case external_links(musicbrainz_data, pattern) do
+44
View File
@@ -24,33 +24,73 @@ defmodule MusicBrainz.Release do
:media
]
@type t :: %__MODULE__{
id: String.t(),
title: String.t(),
disambiguation: String.t() | nil,
packaging: String.t() | nil,
artists: [Artist.t()],
date: String.t() | nil,
barcode: String.t() | nil,
catalog_number: String.t(),
country: String.t() | nil,
media: [Medium.t()]
}
defmodule Artist do
@enforce_keys [:id, :name, :sort_name]
defstruct [:id, :name, :sort_name]
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
sort_name: String.t()
}
end
defmodule Medium do
@enforce_keys [:title, :format, :number, :track_count, :tracks]
defstruct [:title, :format, :number, :track_count, :tracks]
@type t :: %__MODULE__{
title: String.t() | nil,
format: String.t() | nil,
number: non_neg_integer(),
track_count: non_neg_integer(),
tracks: [MusicBrainz.Release.Track.t()]
}
end
defmodule Track do
@enforce_keys [:id, :title, :artists, :length, :number, :position]
defstruct [:id, :title, :artists, :length, :number, :position]
@type t :: %__MODULE__{
id: String.t(),
title: String.t(),
artists: [MusicBrainz.Release.Artist.t()],
length: non_neg_integer() | nil,
number: String.t(),
position: non_neg_integer()
}
end
@spec media_count(t()) :: non_neg_integer()
def media_count(release) do
Enum.count(release.media)
end
@spec get_medium(t(), non_neg_integer()) :: Medium.t() | nil
def get_medium(release, medium_number) do
Enum.find(release.media, fn m -> m.number == medium_number end)
end
@spec medium_duration(Medium.t()) :: non_neg_integer()
def medium_duration(medium) do
Enum.sum_by(medium.tracks, fn track -> track.length || 0 end)
end
@spec medium_tracks(t(), non_neg_integer()) :: [Track.t()]
def medium_tracks(release, medium_number) do
case Enum.find(release.media, fn m -> m.number == medium_number end) do
nil -> []
@@ -58,14 +98,17 @@ defmodule MusicBrainz.Release do
end
end
@spec release_duration(t()) :: non_neg_integer()
def release_duration(release) do
Enum.sum_by(release.media, fn medium -> medium_duration(medium) end)
end
@spec tracks(t()) :: [Track.t()]
def tracks(release) do
Enum.flat_map(release.media, fn medium -> medium.tracks end)
end
@spec from_api_response(map()) :: t()
def from_api_response(r) do
%__MODULE__{
id: r["id"],
@@ -81,6 +124,7 @@ defmodule MusicBrainz.Release do
}
end
@spec thumb_url(t()) :: String.t()
def thumb_url(release) do
"https://coverartarchive.org/release/#{release.id}/front-250"
end
+6
View File
@@ -1,6 +1,7 @@
defmodule MusicBrainz.ReleaseGroup do
alias MusicBrainz.ReleaseGroupSearchResult
@spec included_release_groups(map()) :: [ReleaseGroupSearchResult.t()]
def included_release_groups(release_group) do
release_group
|> get_release_groups()
@@ -9,27 +10,32 @@ defmodule MusicBrainz.ReleaseGroup do
end)
end
@spec releases(map()) :: [map()]
def releases(release_group) do
release_group
|> Map.get("releases", [])
end
@spec release_ids(map()) :: [String.t()]
def release_ids(release_group) do
release_group
|> Map.get("releases", [])
|> Enum.map(fn release -> release["id"] end)
end
@spec included_release_group_ids(map()) :: [String.t()]
def included_release_group_ids(release_group) do
release_group
|> included_release_groups()
|> Enum.map(fn rg -> rg.id end)
end
@spec url(String.t()) :: String.t()
def url(id) do
"https://musicbrainz.org/release-group/#{id}"
end
@spec parse_type(String.t() | nil) :: :album | :ep | :live | :compilation | :single | :other
def parse_type("Album"), do: :album
def parse_type("EP"), do: :ep
def parse_type("Live"), do: :live
@@ -4,6 +4,15 @@ defmodule MusicBrainz.ReleaseGroupSearchResult do
@enforce_keys [:id, :type, :title, :artists, :release_date]
defstruct [:id, :type, :title, :artists, :release_date]
@type t :: %__MODULE__{
id: String.t(),
type: :album | :ep | :live | :compilation | :single | :other,
title: String.t(),
artists: String.t(),
release_date: String.t() | nil
}
@spec from_api_response(map()) :: t()
def from_api_response(rg) do
%__MODULE__{
id: rg["id"],
@@ -14,6 +23,7 @@ defmodule MusicBrainz.ReleaseGroupSearchResult do
}
end
@spec thumb_url(t()) :: String.t()
def thumb_url(rgr) do
"https://coverartarchive.org/release-group/#{rgr.id}/front-250"
end
+14
View File
@@ -4,6 +4,17 @@ defmodule MusicBrainz.ReleaseSearchResult do
@enforce_keys [:id, :title, :release_group, :artists, :date, :barcode, :media]
defstruct [:id, :title, :release_group, :artists, :date, :barcode, :media]
@type t :: %__MODULE__{
id: String.t(),
title: String.t(),
release_group: map() | nil,
artists: String.t(),
date: String.t() | nil,
barcode: String.t() | nil,
media: [map()]
}
@spec from_api_response(map()) :: t()
def from_api_response(r) do
%__MODULE__{
id: r["id"],
@@ -54,6 +65,8 @@ defmodule MusicBrainz.ReleaseSearchResult do
:multi
"""
@spec format(t()) ::
:cd | :vinyl | :dvd | :blu_ray | :digital_download | :vhs | :multi | :unknown
def format(release_search_result) do
sorted_frequencies =
release_search_result.media
@@ -74,6 +87,7 @@ defmodule MusicBrainz.ReleaseSearchResult do
}
end
@spec parse_media([map()]) :: [map()]
def parse_media(media) do
Enum.map(media, fn m ->
%{
+2
View File
@@ -4,6 +4,8 @@ defmodule MusicLibrary.ArtistChat do
alias MusicLibrary.Artists.ArtistInfo
@impl true
@spec stream_response([map()], {map(), ArtistInfo.t()}, (String.t() -> any())) ::
:ok | {:error, term()}
def stream_response(messages, {artist, artist_info}, callback) do
instructions = build_instructions(artist, artist_info)
+34
View File
@@ -6,6 +6,7 @@ defmodule MusicLibrary.Artists do
alias MusicLibrary.Records.{ArtistRecord, Record}
alias MusicLibrary.{Repo, Worker}
@spec get_artist!(String.t()) :: map()
def get_artist!(musicbrainz_id) do
q =
from ar in ArtistRecord,
@@ -16,6 +17,7 @@ defmodule MusicLibrary.Artists do
Repo.one!(q)
end
@spec get_similar_artists(map()) :: {:ok, [map()]} | {:error, term()}
def get_similar_artists(artist) do
case LastFm.get_similar_artists(artist.musicbrainz_id, artist.name) do
{:ok, artists} ->
@@ -31,6 +33,7 @@ defmodule MusicLibrary.Artists do
end
end
@spec name_id_pairs([String.t()]) :: [{String.t(), String.t()}]
def name_id_pairs(names) do
q =
from ar in ArtistRecord,
@@ -41,12 +44,14 @@ defmodule MusicLibrary.Artists do
Repo.all(q)
end
@spec get_all_artist_ids() :: MapSet.t(String.t())
def get_all_artist_ids do
q = from ar in ArtistRecord, distinct: true, select: ar.musicbrainz_id
q |> Repo.all() |> MapSet.new()
end
@spec get_all_artist_pairs() :: [map()]
def get_all_artist_pairs do
q =
from ar in ArtistRecord,
@@ -56,6 +61,7 @@ defmodule MusicLibrary.Artists do
q |> Repo.all()
end
@spec get_image_hashes([map()]) :: %{String.t() => String.t() | nil}
def get_image_hashes(lastfm_artists) do
musicbrainz_ids = Enum.map(lastfm_artists, & &1.musicbrainz_id)
@@ -69,6 +75,7 @@ defmodule MusicLibrary.Artists do
|> Enum.into(%{})
end
@spec exists?(String.t()) :: boolean()
def exists?(artist_id) do
q =
from ar in ArtistRecord,
@@ -77,10 +84,12 @@ defmodule MusicLibrary.Artists do
Repo.exists?(q)
end
@spec delete_artist_info(String.t()) :: {non_neg_integer(), nil | [term()]}
def delete_artist_info(artist_id) do
Repo.delete_all(from ai in ArtistInfo, where: ai.id == ^artist_id)
end
@spec fetch_artist_info(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def fetch_artist_info(artist_id) do
with {:ok, musicbrainz_artist} <- MusicBrainz.get_artist(artist_id) do
if discogs_id = MusicBrainz.Artist.get_discogs_id(musicbrainz_artist) do
@@ -100,6 +109,7 @@ defmodule MusicLibrary.Artists do
end
end
@spec refresh_musicbrainz_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_musicbrainz_data(artist_id) do
with {:ok, musicbrainz_artist} <- MusicBrainz.get_artist(artist_id) do
get_artist_info!(artist_id)
@@ -108,10 +118,13 @@ defmodule MusicLibrary.Artists do
end
end
@spec refresh_musicbrainz_data_async(ArtistInfo.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_musicbrainz_data_async(artist_info) do
enqueue_worker(Worker.ArtistRefreshMusicBrainzData, %{"id" => artist_info.id})
end
@spec refresh_discogs_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_discogs_data(artist_id) do
artist_info = get_artist_info!(artist_id)
@@ -130,10 +143,13 @@ defmodule MusicLibrary.Artists do
end
end
@spec refresh_discogs_data_async(ArtistInfo.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_discogs_data_async(artist_info) do
enqueue_worker(Worker.ArtistRefreshDiscogsData, %{"id" => artist_info.id})
end
@spec fetch_wikipedia_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def fetch_wikipedia_data(artist_id) do
artist_info = get_artist_info!(artist_id)
@@ -152,24 +168,30 @@ defmodule MusicLibrary.Artists do
end
end
@spec refresh_wikipedia_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_wikipedia_data(artist_id) do
fetch_wikipedia_data(artist_id)
end
@spec refresh_wikipedia_data_async(ArtistInfo.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_wikipedia_data_async(artist_info) do
enqueue_worker(Worker.ArtistRefreshWikipediaData, %{"id" => artist_info.id})
end
@spec create_artist_info(map()) :: {:ok, ArtistInfo.t()} | {:error, Ecto.Changeset.t()}
def create_artist_info(attrs) do
%ArtistInfo{}
|> ArtistInfo.changeset(attrs)
|> Repo.insert(on_conflict: {:replace, [:musicbrainz_data, :discogs_data]})
end
@spec get_artist_info!(String.t()) :: ArtistInfo.t()
def get_artist_info!(artist_id) do
Repo.get!(ArtistInfo, artist_id)
end
@spec get_artist_infos([String.t()]) :: [ArtistInfo.t()]
def get_artist_infos(artist_ids) do
q =
from ai in ArtistInfo, where: ai.id in ^artist_ids
@@ -177,6 +199,7 @@ defmodule MusicLibrary.Artists do
Repo.all(q)
end
@spec fetch_image(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def fetch_image(artist_id) do
artist_info = get_artist_info!(artist_id)
@@ -191,6 +214,7 @@ defmodule MusicLibrary.Artists do
end
end
@spec fetch_lastfm_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def fetch_lastfm_data(artist_id) do
artist_info = get_artist_info!(artist_id)
name = get_in(artist_info.musicbrainz_data, ["name"]) || ""
@@ -208,26 +232,36 @@ defmodule MusicLibrary.Artists do
end
end
@spec fetch_lastfm_data_async(String.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def fetch_lastfm_data_async(artist_id) do
enqueue_worker(Worker.FetchArtistLastFmData, %{"id" => artist_id})
end
@spec fetch_artist_info_async(String.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def fetch_artist_info_async(artist_id) do
enqueue_worker(Worker.FetchArtistInfo, %{"id" => artist_id})
end
@spec fetch_image_async(String.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def fetch_image_async(artist_id) do
enqueue_worker(Worker.FetchArtistImage, %{"id" => artist_id})
end
@spec prune_artist_info_async(String.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def prune_artist_info_async(artist_id) do
enqueue_worker(Worker.PruneArtistInfo, %{"id" => artist_id})
end
@spec change_artist_info(ArtistInfo.t(), map()) :: Ecto.Changeset.t()
def change_artist_info(artist_info, attrs \\ %{}) do
ArtistInfo.changeset(artist_info, attrs)
end
@spec update_artist_info(ArtistInfo.t(), map()) ::
{:ok, ArtistInfo.t()} | {:error, Ecto.Changeset.t()}
def update_artist_info(artist_info, attrs) do
artist_info
|> ArtistInfo.changeset(attrs)
+3
View File
@@ -11,6 +11,9 @@ defmodule MusicLibrary.Artists.Artist do
field :joinphrase, :string, default: ""
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(artist, attrs) do
artist
|> cast(attrs, [:name, :sort_name, :disambiguation, :joinphrase, :musicbrainz_id])
+16
View File
@@ -19,6 +19,9 @@ defmodule MusicLibrary.Artists.ArtistInfo do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(artist_info, attrs) do
artist_info
|> cast(attrs, [
@@ -32,6 +35,7 @@ defmodule MusicLibrary.Artists.ArtistInfo do
|> validate_required([:musicbrainz_data])
end
@spec country(t()) :: %{name: String.t(), code: String.t()}
def country(artist_info) do
%{"area" => area} =
artist_info.musicbrainz_data
@@ -45,6 +49,9 @@ defmodule MusicLibrary.Artists.ArtistInfo do
%{name: area["name"] || "World", code: country_code || "XW"}
end
@spec extract_image(t()) ::
{:ok, %{url: String.t(), width: integer()}}
| {:error, :no_discogs_data | :image_not_found}
def extract_image(artist_info) when is_nil(artist_info.discogs_data) do
{:error, :no_discogs_data}
end
@@ -71,9 +78,11 @@ defmodule MusicLibrary.Artists.ArtistInfo do
"ProgArchives" => "progarchives.com"
}
@spec external_links(t()) :: [map()]
def external_links(artist_info),
do: ExternalLink.external_links(artist_info.musicbrainz_data, @external_link_patterns)
@spec discogs_id(t()) :: integer() | nil
def discogs_id(artist_info) do
case artist_info.discogs_data do
%{"id" => discogs_id} -> discogs_id
@@ -81,6 +90,7 @@ defmodule MusicLibrary.Artists.ArtistInfo do
end
end
@spec wikidata_id(t()) :: String.t() | nil
def wikidata_id(artist_info) do
relations = get_in(artist_info.musicbrainz_data, ["relations"]) || []
@@ -93,27 +103,33 @@ defmodule MusicLibrary.Artists.ArtistInfo do
end)
end
@spec wikipedia_bio(t()) :: String.t() | nil
def wikipedia_bio(artist_info) do
get_in(artist_info.wikipedia_data, ["intro_html"]) ||
get_in(artist_info.wikipedia_data, ["extract_html"])
end
@spec wikipedia_summary(t()) :: String.t() | nil
def wikipedia_summary(artist_info) do
get_in(artist_info.wikipedia_data, ["extract"])
end
@spec wikipedia_url(t()) :: String.t() | nil
def wikipedia_url(artist_info) do
get_in(artist_info.wikipedia_data, ["content_urls", "desktop", "page"])
end
@spec wikipedia_description(t()) :: String.t() | nil
def wikipedia_description(artist_info) do
get_in(artist_info.wikipedia_data, ["description"])
end
@spec lastfm_tags(t()) :: [map()]
def lastfm_tags(artist_info) do
get_in(artist_info.lastfm_data, ["tags"]) || []
end
@spec lastfm_similar_artists(t()) :: [map()]
def lastfm_similar_artists(artist_info) do
get_in(artist_info.lastfm_data, ["similar_artists"]) || []
end
+4
View File
@@ -5,24 +5,28 @@ defmodule MusicLibrary.Artists.Batch do
alias MusicLibrary.Artists.ArtistInfo
alias MusicLibrary.Batch
@spec refresh_musicbrainz_data() :: {:ok, [String.t()]}
def refresh_musicbrainz_data do
Batch.run_on_all(from(r in ArtistInfo), "artist_info", fn artist_info ->
Artists.refresh_musicbrainz_data_async(artist_info)
end)
end
@spec refresh_discogs_data() :: {:ok, [String.t()]}
def refresh_discogs_data do
Batch.run_on_all(from(r in ArtistInfo), "artist_info", fn artist_info ->
Artists.refresh_discogs_data_async(artist_info)
end)
end
@spec refresh_wikipedia_data() :: {:ok, [String.t()]}
def refresh_wikipedia_data do
Batch.run_on_all(from(r in ArtistInfo), "artist_info", fn artist_info ->
Artists.refresh_wikipedia_data_async(artist_info)
end)
end
@spec refresh_lastfm_data() :: {:ok, [String.t()]}
def refresh_lastfm_data do
Batch.run_on_all(from(r in ArtistInfo), "artist_info", fn artist_info ->
Artists.fetch_lastfm_data_async(artist_info.id)
+7
View File
@@ -8,6 +8,7 @@ defmodule MusicLibrary.Assets do
Store any file type - the responsibility to correctly populate format and
properties is left to the caller.
"""
@spec store(map()) :: {:ok, Asset.t()} | {:error, Ecto.Changeset.t()}
def store(params) do
%Asset{}
|> Asset.changeset(params)
@@ -17,20 +18,24 @@ defmodule MusicLibrary.Assets do
@doc """
Store image files - properties will be computed automatically.
"""
@spec store_image(map()) :: {:ok, Asset.t()} | {:error, Ecto.Changeset.t()}
def store_image(params) do
%Asset{}
|> Asset.image_changeset(params)
|> Repo.insert(on_conflict: :nothing, returning: true)
end
@spec get(String.t()) :: Asset.t() | nil
def get(hash) do
Repo.get(Asset, hash)
end
@spec get!(String.t()) :: Asset.t()
def get!(hash) do
Repo.get!(Asset, hash)
end
@spec total_content_size() :: non_neg_integer() | nil
def total_content_size do
q =
from p in Asset, select: sum(fragment("length(content)"))
@@ -38,6 +43,7 @@ defmodule MusicLibrary.Assets do
Repo.one(q)
end
@spec track_total_content_size() :: :ok
def track_total_content_size do
:telemetry.execute(
[:music_library, :assets],
@@ -46,6 +52,7 @@ defmodule MusicLibrary.Assets do
)
end
@spec track_total_cache_size() :: :ok
def track_total_cache_size do
:telemetry.execute(
[:music_library, :assets],
+5
View File
@@ -14,6 +14,9 @@ defmodule MusicLibrary.Assets.Asset do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(asset, attrs) do
asset
|> cast(attrs, [:content, :format, :properties])
@@ -22,6 +25,7 @@ defmodule MusicLibrary.Assets.Asset do
|> unique_constraint(:hash)
end
@spec image_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def image_changeset(asset, attrs) do
asset
|> cast(attrs, [:content, :format])
@@ -60,6 +64,7 @@ defmodule MusicLibrary.Assets.Asset do
}
end
@spec hash(binary()) :: String.t()
def hash(content) do
:crypto.hash(:sha256, content) |> Base.encode16()
end
+5
View File
@@ -1,13 +1,16 @@
defmodule MusicLibrary.Assets.Cache do
@spec new() :: :ets.table()
def new do
:ets.new(__MODULE__, [:named_table, :public, :compressed, read_concurrency: true])
end
@spec set(String.t(), String.t(), binary()) :: true
def set(payload, format, content) do
inserted_at = DateTime.utc_now() |> DateTime.to_unix()
:ets.insert(__MODULE__, {{payload, format}, inserted_at, content})
end
@spec get(String.t(), String.t()) :: {:found, binary()} | :not_found
def get(payload, format) do
case :ets.lookup(__MODULE__, {payload, format}) do
[{{^payload, ^format}, _inserted_at, content}] -> {:found, content}
@@ -15,6 +18,7 @@ defmodule MusicLibrary.Assets.Cache do
end
end
@spec total_content_size() :: non_neg_integer()
def total_content_size do
:ets.foldl(
fn {_key, _inserted_at, content}, acc -> acc + byte_size(content) end,
@@ -23,6 +27,7 @@ defmodule MusicLibrary.Assets.Cache do
)
end
@spec prune(non_neg_integer()) :: non_neg_integer()
def prune(older_than_seconds) do
threshold =
DateTime.utc_now()
+3
View File
@@ -9,13 +9,16 @@ defmodule MusicLibrary.Assets.Image do
@default_size 2000
@default_format "image/jpeg"
@spec fallback_data() :: binary()
def fallback_data, do: unquote(fallback_data)
@spec resize(binary(), pos_integer(), String.t()) :: {:ok, binary()} | {:error, term()}
def resize(cover_data, size \\ @default_size, format \\ @default_format) do
{:ok, thumb} = Operation.thumbnail_buffer(cover_data, size)
Image.write_to_buffer(thumb, extension(format))
end
@spec convert(binary(), String.t(), String.t()) :: {:ok, binary()}
def convert(cover_data, data_format, target_format) do
if data_format == target_format do
{:ok, cover_data}
+1
View File
@@ -5,6 +5,7 @@ defmodule MusicLibrary.Assets.Transform do
@type t :: %__MODULE__{}
@type payload :: String.t()
@spec new(map()) :: t()
def new(attrs \\ %{}), do: struct!(__MODULE__, attrs)
@doc """
+2
View File
@@ -2,6 +2,7 @@ defmodule MusicLibrary.BarcodeScan do
alias MusicLibrary.BarcodeScan.Result
alias MusicLibrary.Records
@spec scan(String.t()) :: {:ok, Result.t()} | {:error, term()}
def scan(number) do
case MusicBrainz.search_release_by_barcode(number) do
{:ok, [best_match_release | _other_releases]} ->
@@ -26,6 +27,7 @@ defmodule MusicLibrary.BarcodeScan do
end
end
@spec import_results([Result.t()], DateTime.t()) :: [{String.t(), term()}]
def import_results(scan_results, current_time) do
Enum.reduce(scan_results, [], fn scan_result, errors ->
case import_result(scan_result, current_time) do
+11
View File
@@ -1,6 +1,14 @@
defmodule MusicLibrary.BarcodeScan.Result do
defstruct [:status, :number, :record_id, :release]
@type t :: %__MODULE__{
status: :new | :wishlisted | :collected | :not_found,
number: String.t(),
record_id: String.t() | nil,
release: map() | nil
}
@spec new(String.t(), map()) :: t()
def new(number, release) do
%__MODULE__{
number: number,
@@ -9,6 +17,7 @@ defmodule MusicLibrary.BarcodeScan.Result do
}
end
@spec wishlisted(String.t(), String.t(), map()) :: t()
def wishlisted(number, record_id, release) do
%__MODULE__{
number: number,
@@ -18,6 +27,7 @@ defmodule MusicLibrary.BarcodeScan.Result do
}
end
@spec collected(String.t(), String.t(), map()) :: t()
def collected(number, record_id, release) do
%__MODULE__{
number: number,
@@ -27,6 +37,7 @@ defmodule MusicLibrary.BarcodeScan.Result do
}
end
@spec not_found(String.t()) :: t()
def not_found(number) do
%__MODULE__{
number: number,
+3
View File
@@ -3,6 +3,9 @@ defmodule MusicLibrary.Batch do
require Logger
@spec run_on_all(Ecto.Queryable.t(), String.t(), (struct() ->
:ok | {:ok, term()} | {:error, term()})) ::
{:ok, [String.t()]}
def run_on_all(queryable, label, fun) do
stream = Repo.stream(queryable, max_rows: 50)
+11
View File
@@ -8,6 +8,7 @@ defmodule MusicLibrary.Collection do
@pagination Application.compile_env!(:music_library, :pagination)
@spec search_records(String.t(), MusicLibrary.Types.pagination_opts()) :: [SearchIndex.t()]
def search_records(query, opts \\ []) do
limit = Keyword.get(opts, :limit, @pagination[:default_page_size])
offset = Keyword.get(opts, :offset, 0)
@@ -16,10 +17,12 @@ defmodule MusicLibrary.Collection do
Records.search_records(base_search(), query, limit: limit, offset: offset, order: order)
end
@spec search_records_count(String.t()) :: non_neg_integer()
def search_records_count(query) do
Records.search_records_count(base_search(), query)
end
@spec count_records_by_format() :: [{String.t(), non_neg_integer()}]
def count_records_by_format do
q =
from r in Record,
@@ -31,6 +34,7 @@ defmodule MusicLibrary.Collection do
Repo.all(q)
end
@spec count_records_by_type() :: [{String.t(), non_neg_integer()}]
def count_records_by_type do
q =
from r in Record,
@@ -42,6 +46,7 @@ defmodule MusicLibrary.Collection do
Repo.all(q)
end
@spec get_records_on_this_day(Date.t()) :: [SearchIndex.t()]
def get_records_on_this_day(date \\ Date.utc_today()) do
month_day = Calendar.strftime(date, "%m-%d")
@@ -55,6 +60,7 @@ defmodule MusicLibrary.Collection do
Repo.all(q)
end
@spec get_latest_record() :: SearchIndex.t() | nil
def get_latest_record do
q =
from r in Record,
@@ -66,6 +72,7 @@ defmodule MusicLibrary.Collection do
Repo.one(q)
end
@spec get_latest_record!() :: SearchIndex.t()
def get_latest_record! do
q =
from r in Record,
@@ -77,6 +84,7 @@ defmodule MusicLibrary.Collection do
Repo.one!(q)
end
@spec get_random_record!() :: SearchIndex.t()
def get_random_record! do
q =
from r in Record,
@@ -88,6 +96,7 @@ defmodule MusicLibrary.Collection do
Repo.one!(q)
end
@spec count_records_by_artist(keyword()) :: [map()]
def count_records_by_artist(opts \\ []) do
limit = Keyword.get(opts, :limit, @pagination[:stats_limit])
@@ -106,6 +115,7 @@ defmodule MusicLibrary.Collection do
Repo.all(q)
end
@spec count_records_by_genre(keyword()) :: [{String.t(), non_neg_integer()}]
def count_records_by_genre(opts \\ []) do
limit = Keyword.get(opts, :limit, @pagination[:stats_limit])
@@ -122,6 +132,7 @@ defmodule MusicLibrary.Collection do
Repo.all(q)
end
@spec collected_releases_query() :: Ecto.Query.t()
def collected_releases_query do
from rr in RecordRelease,
where: not is_nil(rr.purchased_at),
@@ -5,6 +5,7 @@ defmodule MusicLibrary.Colors.KMeansExtractor do
alias Vix.Vips.Image
@spec extract_dominant_colors(binary(), pos_integer()) :: {:ok, [String.t()]} | {:error, term()}
def extract_dominant_colors(image_data, num_colors \\ 5) do
with {:ok, dir} <- Briefly.create(type: :directory),
path = dir <> "/temp_image.jpg",
+1
View File
@@ -29,6 +29,7 @@ defmodule MusicLibrary.FormatNumber do
Numbers less than 1,000 are returned as-is.
Numbers 1,000 and above are formatted with appropriate suffix and one decimal place.
"""
@spec to_compact(integer()) :: String.t()
def to_compact(number) when is_integer(number) and number < 1000 do
Integer.to_string(number)
end
+16
View File
@@ -14,10 +14,19 @@ defmodule MusicLibrary.ListeningStats do
@pagination Application.compile_env!(:music_library, :pagination)
@type period_opts :: [
period: atom(),
limit: non_neg_integer(),
current_time: DateTime.t(),
timezone: String.t()
]
@spec scrobble_count() :: non_neg_integer()
def scrobble_count do
Repo.aggregate(Track, :count, :scrobbled_at_uts)
end
@spec recent_activity(String.t(), non_neg_integer()) :: map()
def recent_activity(timezone, limit \\ 100) do
# When we get recent tracks, we need to:
#
@@ -81,6 +90,7 @@ defmodule MusicLibrary.ListeningStats do
}
end
@spec localize_scrobbled_at(integer(), String.t()) :: String.t()
def localize_scrobbled_at(uts, timezone) do
ldt =
uts
@@ -94,6 +104,7 @@ defmodule MusicLibrary.ListeningStats do
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.
"""
@spec get_top_albums_by_period(period_opts()) :: [map()]
def get_top_albums_by_period(opts) do
case Keyword.get(opts, :period, :last_7_days) do
:all_time -> get_top_albums(opts)
@@ -107,6 +118,7 @@ defmodule MusicLibrary.ListeningStats do
@doc """
Gets top artists for a time period (7, 30, 90, 365 days) and all time.
"""
@spec get_top_artists_by_period(period_opts()) :: [map()]
def get_top_artists_by_period(opts) do
case Keyword.get(opts, :period, :last_7_days) do
:all_time -> get_top_artists(opts)
@@ -121,6 +133,7 @@ defmodule MusicLibrary.ListeningStats do
Gets the top albums by scrobble count across all time.
Returns a list of maps with album information and play counts.
"""
@spec get_top_albums(period_opts()) :: [map()]
def get_top_albums(opts) do
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
@@ -133,6 +146,7 @@ defmodule MusicLibrary.ListeningStats do
Gets the top albums by scrobble count for the given number of days.
Returns a list of maps with album information and play counts.
"""
@spec get_top_albums_by_days(pos_integer(), period_opts()) :: [map()]
def get_top_albums_by_days(days, opts) do
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
cutoff_timestamp = cutoff_timestamp(days, opts)
@@ -147,6 +161,7 @@ defmodule MusicLibrary.ListeningStats do
Gets the top artists by scrobble count across all time.
Returns a list of maps with artist information and play counts.
"""
@spec get_top_artists(period_opts()) :: [map()]
def get_top_artists(opts) do
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
@@ -159,6 +174,7 @@ defmodule MusicLibrary.ListeningStats do
Gets the top artists by scrobble count for the given number of days.
Returns a list of maps with artist information and play counts.
"""
@spec get_top_artists_by_days(pos_integer(), period_opts()) :: [map()]
def get_top_artists_by_days(days, opts) do
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
cutoff_timestamp = cutoff_timestamp(days, opts)
+3
View File
@@ -13,6 +13,7 @@ defmodule MusicLibrary.Maintenance do
Active jobs are those in "available", "scheduled", "executing", or "retryable" states.
"""
@spec count_active_jobs(String.t()) :: non_neg_integer() | nil
def count_active_jobs(worker) do
query =
from j in Oban.Job,
@@ -26,6 +27,7 @@ defmodule MusicLibrary.Maintenance do
@doc """
Runs VACUUM on the main database.
"""
@spec vacuum() :: :ok
def vacuum do
Repo.vacuum()
end
@@ -33,6 +35,7 @@ defmodule MusicLibrary.Maintenance do
@doc """
Runs PRAGMA optimize on the main database.
"""
@spec optimize() :: :ok
def optimize do
Repo.optimize()
end
+4
View File
@@ -2,22 +2,26 @@ defmodule MusicLibrary.Notes do
alias MusicLibrary.Notes.Note
alias MusicLibrary.Repo
@spec get_note(atom(), String.t()) :: Note.t() | nil
def get_note(entity, musicbrainz_id) do
Repo.get_by(Note, entity: entity, musicbrainz_id: musicbrainz_id)
end
@spec create_note(Note.t(), map()) :: {:ok, Note.t()} | {:error, Ecto.Changeset.t()}
def create_note(note, attrs) do
note
|> Note.changeset(attrs)
|> Repo.insert()
end
@spec update_note(Note.t(), map()) :: {:ok, Note.t()} | {:error, Ecto.Changeset.t()}
def update_note(note, attrs) do
note
|> Note.changeset(attrs)
|> Repo.update()
end
@spec change_note(Note.t(), map()) :: Ecto.Changeset.t()
def change_note(note, attrs) do
Note.changeset(note, attrs)
end
+3
View File
@@ -12,6 +12,9 @@ defmodule MusicLibrary.Notes.Note do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(note, attrs) do
note
|> cast(attrs, [
@@ -8,6 +8,7 @@ defmodule MusicLibrary.OnlineStoreTemplates do
alias MusicLibrary.OnlineStoreTemplates.OnlineStoreTemplate
alias MusicLibrary.Repo
@spec list_enabled_templates() :: [OnlineStoreTemplate.t()]
def list_enabled_templates do
OnlineStoreTemplate
|> where([t], t.enabled == true)
@@ -15,34 +16,43 @@ defmodule MusicLibrary.OnlineStoreTemplates do
|> Repo.all()
end
@spec list_templates() :: [OnlineStoreTemplate.t()]
def list_templates do
OnlineStoreTemplate
|> order_by([t], fragment("? COLLATE NOCASE ASC", t.name))
|> Repo.all()
end
@spec get_template!(String.t()) :: OnlineStoreTemplate.t()
def get_template!(id), do: Repo.get!(OnlineStoreTemplate, id)
@spec create_template(map()) :: {:ok, OnlineStoreTemplate.t()} | {:error, Ecto.Changeset.t()}
def create_template(attrs \\ %{}) do
%OnlineStoreTemplate{}
|> OnlineStoreTemplate.changeset(attrs)
|> Repo.insert()
end
@spec update_template(OnlineStoreTemplate.t(), map()) ::
{:ok, OnlineStoreTemplate.t()} | {:error, Ecto.Changeset.t()}
def update_template(%OnlineStoreTemplate{} = template, attrs) do
template
|> OnlineStoreTemplate.changeset(attrs)
|> Repo.update()
end
@spec delete_template(OnlineStoreTemplate.t()) ::
{:ok, OnlineStoreTemplate.t()} | {:error, Ecto.Changeset.t()}
def delete_template(%OnlineStoreTemplate{} = template) do
Repo.delete(template)
end
@spec change_template(OnlineStoreTemplate.t(), map()) :: Ecto.Changeset.t()
def change_template(%OnlineStoreTemplate{} = template, attrs \\ %{}) do
OnlineStoreTemplate.changeset(template, attrs)
end
@spec generate_url(OnlineStoreTemplate.t(), map()) :: String.t()
def generate_url(template, record) do
artists_string = Enum.map_join(record.artists, " ", & &1.name)
format_string = Atom.to_string(record.format)
@@ -14,7 +14,10 @@ defmodule MusicLibrary.OnlineStoreTemplates.OnlineStoreTemplate do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@doc false
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(template, attrs) do
template
|> cast(attrs, [:name, :description, :url_template, :enabled])
+2
View File
@@ -4,6 +4,8 @@ defmodule MusicLibrary.RecordChat do
alias MusicLibrary.Records.Record
@impl true
@spec stream_response([map()], {Record.t(), String.t() | nil}, (String.t() -> any())) ::
:ok | {:error, term()}
def stream_response(messages, {record, embedding_text}, callback) do
instructions = build_instructions(record, embedding_text)
+16
View File
@@ -6,6 +6,7 @@ defmodule MusicLibrary.RecordSets do
@pagination Application.compile_env!(:music_library, :pagination)
@spec list_record_sets(MusicLibrary.Types.pagination_opts()) :: [RecordSet.t()]
def list_record_sets(opts \\ []) do
offset = Keyword.get(opts, :offset, 0)
limit = Keyword.get(opts, :limit, @pagination[:default_page_size])
@@ -19,15 +20,18 @@ defmodule MusicLibrary.RecordSets do
|> Repo.all()
end
@spec count_record_sets() :: non_neg_integer()
def count_record_sets do
Repo.aggregate(RecordSet, :count)
end
@spec count_record_sets(String.t()) :: non_neg_integer()
def count_record_sets(query) do
record_sets_search_query(query)
|> Repo.aggregate(:count)
end
@spec search_record_sets(String.t(), MusicLibrary.Types.pagination_opts()) :: [RecordSet.t()]
def search_record_sets(query, opts \\ []) do
offset = Keyword.get(opts, :offset, 0)
limit = Keyword.get(opts, :limit, @pagination[:default_page_size])
@@ -49,12 +53,14 @@ defmodule MusicLibrary.RecordSets do
order_by(query, [rs], desc: rs.updated_at)
end
@spec get_record_set!(String.t()) :: RecordSet.t()
def get_record_set!(id) do
RecordSet
|> Repo.get!(id)
|> Repo.preload(items: :record)
end
@spec create_record_set(map()) :: {:ok, RecordSet.t()} | {:error, Ecto.Changeset.t()}
def create_record_set(attrs) do
%RecordSet{}
|> RecordSet.changeset(attrs)
@@ -65,6 +71,8 @@ defmodule MusicLibrary.RecordSets do
end
end
@spec update_record_set(RecordSet.t(), map()) ::
{:ok, RecordSet.t()} | {:error, Ecto.Changeset.t()}
def update_record_set(%RecordSet{} = record_set, attrs) do
record_set
|> RecordSet.changeset(attrs)
@@ -75,14 +83,18 @@ defmodule MusicLibrary.RecordSets do
end
end
@spec delete_record_set(RecordSet.t()) :: {:ok, RecordSet.t()} | {:error, Ecto.Changeset.t()}
def delete_record_set(%RecordSet{} = record_set) do
Repo.delete(record_set)
end
@spec change_record_set(RecordSet.t(), map()) :: Ecto.Changeset.t()
def change_record_set(%RecordSet{} = record_set, attrs \\ %{}) do
RecordSet.changeset(record_set, attrs)
end
@spec add_record_to_set(RecordSet.t(), String.t()) ::
{:ok, RecordSet.t()} | {:error, Ecto.Changeset.t()}
def add_record_to_set(%RecordSet{} = record_set, record_id) do
next_position =
from(i in RecordSetItem,
@@ -104,6 +116,7 @@ defmodule MusicLibrary.RecordSets do
end
end
@spec remove_record_from_set(RecordSet.t(), String.t()) :: {:ok, RecordSet.t()}
def remove_record_from_set(%RecordSet{} = record_set, record_id) do
item =
from(i in RecordSetItem,
@@ -118,6 +131,7 @@ defmodule MusicLibrary.RecordSets do
{:ok, get_record_set!(record_set.id)}
end
@spec reorder_records_in_set(RecordSet.t(), [String.t()]) :: {:ok, RecordSet.t()}
def reorder_records_in_set(%RecordSet{} = record_set, ordered_record_ids)
when is_list(ordered_record_ids) do
Repo.transaction(fn ->
@@ -134,6 +148,7 @@ defmodule MusicLibrary.RecordSets do
{:ok, get_record_set!(record_set.id)}
end
@spec move_record_in_set(RecordSet.t(), String.t(), :up | :down) :: {:ok, RecordSet.t()}
def move_record_in_set(%RecordSet{} = record_set, record_id, direction)
when direction in [:up, :down] do
items =
@@ -169,6 +184,7 @@ defmodule MusicLibrary.RecordSets do
{:ok, get_record_set!(record_set.id)}
end
@spec list_record_sets_for_record(String.t()) :: [RecordSet.t()]
def list_record_sets_for_record(record_id) do
from(rs in RecordSet,
join: i in RecordSetItem,
@@ -16,6 +16,9 @@ defmodule MusicLibrary.RecordSets.RecordSet do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(record_set, attrs) do
record_set
|> cast(attrs, [:name, :description])
@@ -24,6 +27,11 @@ defmodule MusicLibrary.RecordSets.RecordSet do
|> validate_length(:description, max: 10_000)
end
@spec count_by_status(t()) :: %{
collected: non_neg_integer(),
wishlisted: non_neg_integer(),
total: non_neg_integer()
}
def count_by_status(record_set) do
record_set.items
|> Enum.frequencies_by(fn item ->
@@ -17,6 +17,9 @@ defmodule MusicLibrary.RecordSets.RecordSetItem do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(record_set_item, attrs) do
record_set_item
|> cast(attrs, [:position])
+38
View File
@@ -11,10 +11,19 @@ defmodule MusicLibrary.Records do
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex, SearchParser}
alias MusicLibrary.{Repo, Worker}
@type import_opts :: [
format: atom(),
purchased_at: DateTime.t() | nil,
selected_release_id: String.t() | nil
]
@spec essential_fields() :: [atom()]
def essential_fields do
SearchIndex.__schema__(:fields)
end
@spec search_records(Ecto.Queryable.t(), String.t(), MusicLibrary.Types.pagination_opts()) ::
[SearchIndex.t()]
def search_records(initial_search, query, opts) do
limit = Keyword.fetch!(opts, :limit)
offset = Keyword.fetch!(opts, :offset)
@@ -30,6 +39,7 @@ defmodule MusicLibrary.Records do
Repo.all(search)
end
@spec search_records_count(Ecto.Queryable.t(), String.t()) :: non_neg_integer()
def search_records_count(initial_search, query) do
search = build_search(initial_search, query)
@@ -158,6 +168,7 @@ defmodule MusicLibrary.Records do
end)
end
@spec list_genres() :: [String.t()]
def list_genres do
q =
from r in fragment("records, json_each(records.genres)"),
@@ -167,8 +178,11 @@ defmodule MusicLibrary.Records do
Repo.all(q)
end
@spec get_record!(String.t()) :: Record.t()
def get_record!(id), do: Repo.get!(Record, id)
@spec get_release_status(String.t(), String.t()) ::
:new | {:wishlisted, String.t()} | {:collected, String.t()}
def get_release_status(release_id, format) do
q =
from r in fragment("records, json_each(records.release_ids)"),
@@ -185,6 +199,7 @@ defmodule MusicLibrary.Records do
end
end
@spec get_artist_records(String.t()) :: [SearchIndex.t()]
def get_artist_records(musicbrainz_id) do
q =
from r in Record,
@@ -195,6 +210,8 @@ defmodule MusicLibrary.Records do
Repo.all(q)
end
@spec import_from_musicbrainz_release(String.t(), import_opts()) ::
{:ok, Record.t()} | {:error, term()}
def import_from_musicbrainz_release(musicbrainz_id, opts \\ []) do
case MusicBrainz.get_release(musicbrainz_id) do
{:ok, release} ->
@@ -206,6 +223,8 @@ defmodule MusicLibrary.Records do
end
end
@spec import_from_musicbrainz_release_group(String.t(), import_opts()) ::
{:ok, Record.t()} | {:error, term()}
def import_from_musicbrainz_release_group(musicbrainz_id, opts \\ []) do
format = Keyword.get(opts, :format, "cd")
purchased_at = Keyword.get(opts, :purchased_at)
@@ -226,6 +245,7 @@ defmodule MusicLibrary.Records do
end
end
@spec populate_genres(Record.t()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t()}
def populate_genres(record) do
artists = Enum.map_join(record.artists, ",", fn a -> a.name end)
@@ -246,6 +266,7 @@ defmodule MusicLibrary.Records do
|> Repo.update()
end
@spec populate_genres_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def populate_genres_async(record) do
enqueue_worker(Worker.PopulateGenres, %{"id" => record.id}, record_meta(record))
end
@@ -257,6 +278,7 @@ defmodule MusicLibrary.Records do
end
end
@spec get_last_listened_track(Record.t()) :: LastFm.Track.t() | nil
def get_last_listened_track(record) do
q =
from t in scrobbles_for_record_query(record),
@@ -266,6 +288,7 @@ defmodule MusicLibrary.Records do
Repo.one(q)
end
@spec play_count(Record.t()) :: non_neg_integer()
def play_count(record) do
record
|> scrobbles_for_record_query()
@@ -291,6 +314,7 @@ defmodule MusicLibrary.Records do
fragment("? ->> '$.name'", t.artist) == ^main_artist_name
end
@spec refresh_cover(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def refresh_cover(record) do
with {:ok, cover_data} <- MusicBrainz.get_cover_art({:url, record.cover_url}),
{:ok, thumb_data} <- Assets.Image.resize(cover_data),
@@ -301,10 +325,12 @@ defmodule MusicLibrary.Records do
end
end
@spec refresh_cover_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_cover_async(record) do
enqueue_worker(Worker.RefreshCover, %{"id" => record.id}, record_meta(record))
end
@spec extract_colors(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def extract_colors(record) do
asset = Assets.get!(record.cover_hash)
@@ -313,6 +339,8 @@ defmodule MusicLibrary.Records do
end
end
@spec generate_embedding_async(Record.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def generate_embedding_async(record) do
enqueue_worker(
Worker.GenerateRecordEmbedding,
@@ -321,6 +349,7 @@ defmodule MusicLibrary.Records do
)
end
@spec resize_cover(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def resize_cover(record) do
with {:ok, thumb_data} <- Assets.Image.resize(record.cover_data),
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
@@ -330,6 +359,7 @@ defmodule MusicLibrary.Records do
end
end
@spec refresh_musicbrainz_data(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def refresh_musicbrainz_data(record) do
with {:ok, data} <- MusicBrainz.get_release_group(record.musicbrainz_id),
{:ok, data_with_releases} <- merge_releases(record.musicbrainz_id, data) do
@@ -339,6 +369,8 @@ defmodule MusicLibrary.Records do
end
end
@spec refresh_musicbrainz_data_async(Record.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_musicbrainz_data_async(record) do
enqueue_worker(Worker.RecordRefreshMusicBrainzData, %{"id" => record.id}, record_meta(record))
end
@@ -374,6 +406,7 @@ defmodule MusicLibrary.Records do
|> Map.merge(attrs)
end
@spec create_record(map()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t()}
def create_record(attrs \\ %{}) do
with {:ok, record} <- do_create_record(attrs) do
{:ok, record} = extract_colors(record)
@@ -395,12 +428,14 @@ defmodule MusicLibrary.Records do
|> Repo.insert()
end
@spec update_record(Record.t(), map()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t()}
def update_record(%Record{} = record, attrs) do
record
|> Record.changeset(attrs)
|> Repo.update()
end
@spec delete_record(Record.t()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t()}
def delete_record(%Record{} = record) do
with {:ok, record} <- Repo.delete(record) do
record
@@ -413,14 +448,17 @@ defmodule MusicLibrary.Records do
end
end
@spec change_record(Record.t(), map()) :: Ecto.Changeset.t()
def change_record(%Record{} = record, attrs \\ %{}) do
Record.changeset(record, attrs)
end
@spec subscribe(String.t()) :: :ok | {:error, term()}
def subscribe(record_id) do
Phoenix.PubSub.subscribe(MusicLibrary.PubSub, "records:#{record_id}")
end
@spec notify_update(Record.t()) :: :ok | {:error, term()}
def notify_update(record) do
Phoenix.PubSub.broadcast(
MusicLibrary.PubSub,
@@ -16,4 +16,6 @@ defmodule MusicLibrary.Records.ArtistRecord do
embeds_one :artist, Artist
end
@type t :: %__MODULE__{}
end
+2
View File
@@ -5,12 +5,14 @@ defmodule MusicLibrary.Records.Batch do
alias MusicLibrary.Records
alias MusicLibrary.Records.Record
@spec refresh_musicbrainz_data() :: {:ok, [String.t()]}
def refresh_musicbrainz_data do
Batch.run_on_all(from(r in Record), "record", fn record ->
Records.refresh_musicbrainz_data_async(record)
end)
end
@spec generate_embeddings() :: {:ok, [String.t()]}
def generate_embeddings do
Batch.run_on_all(from(r in Record), "record", fn record ->
Records.generate_embedding_async(record)
+23
View File
@@ -33,10 +33,14 @@ defmodule MusicLibrary.Records.Record do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec artist_names(t()) :: String.t()
def artist_names(record) do
Enum.map_join(record.artists, ", ", fn artist -> artist.name end)
end
@spec main_artist(t()) :: Artist.t() | nil
def main_artist(record) do
case record.artists do
[] -> nil
@@ -44,27 +48,35 @@ defmodule MusicLibrary.Records.Record do
end
end
@spec artist_ids(t()) :: [String.t()]
def artist_ids(record) do
Enum.map(record.artists, fn artist -> artist.musicbrainz_id end)
end
@spec formats() :: [atom()]
def formats, do: @formats
@spec types() :: [atom()]
def types, do: @types
@spec included_release_groups(t()) :: [map()]
def included_release_groups(record) do
record.musicbrainz_data
|> ReleaseGroup.included_release_groups()
|> Enum.filter(fn rg -> rg.id in record.included_release_group_ids end)
end
@spec included_release_groups_count(t()) :: non_neg_integer()
def included_release_groups_count(record) do
Enum.count(record.included_release_group_ids)
end
@spec release_count(t()) :: non_neg_integer()
def release_count(record) do
Enum.count(record.release_ids)
end
@spec released?(t(), Date.t()) :: boolean()
def released?(%{release_date: nil}, _current_day), do: true
def released?(record, current_day) do
@@ -80,6 +92,7 @@ defmodule MusicLibrary.Records.Record do
end
end
@spec released_how_long_ago?(t(), Date.t()) :: non_neg_integer() | nil
def released_how_long_ago?(%{release_date: nil}, _current_day), do: nil
def released_how_long_ago?(record, current_day) do
@@ -95,6 +108,7 @@ defmodule MusicLibrary.Records.Record do
end
end
@spec releases(t()) :: [MusicBrainz.Release.t()]
def releases(record) do
record.musicbrainz_data
|> ReleaseGroup.releases()
@@ -102,16 +116,19 @@ defmodule MusicLibrary.Records.Record do
|> Enum.sort_by(fn r -> {r.date, r.country} end, :desc)
end
@spec selected_release(t()) :: MusicBrainz.Release.t() | nil
def selected_release(record) do
find_release(record, record.selected_release_id)
end
@spec find_release(t(), String.t() | nil) :: MusicBrainz.Release.t() | nil
def find_release(record, release_id) do
record
|> releases()
|> Enum.find(fn release -> release.id == release_id end)
end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(record, attrs) do
record
|> cast(attrs, [
@@ -137,15 +154,18 @@ defmodule MusicLibrary.Records.Record do
|> update_included_release_group_ids()
end
@spec add_genres(t(), [String.t()]) :: Ecto.Changeset.t()
def add_genres(record, genres) do
change(record, genres: genres)
end
@spec set_cover_hash(t(), String.t()) :: Ecto.Changeset.t()
def set_cover_hash(record, cover_hash) do
record
|> change(cover_hash: cover_hash)
end
@spec add_musicbrainz_data(t(), map()) :: Ecto.Changeset.t()
def add_musicbrainz_data(record, musicbrainz_data) do
record
|> change()
@@ -156,6 +176,7 @@ defmodule MusicLibrary.Records.Record do
|> update_musicbrainz_data_derived_fields()
end
@spec rotate_dominant_colors(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t()
def rotate_dominant_colors(%__MODULE__{dominant_colors: dominant_colors} = record) do
change(record, dominant_colors: rotate(dominant_colors))
end
@@ -218,6 +239,7 @@ defmodule MusicLibrary.Records.Record do
end
end
@spec attrs_from_release_group(map()) :: map()
def attrs_from_release_group(release_group) do
musicbrainz_id = release_group["id"]
@@ -293,6 +315,7 @@ defmodule MusicLibrary.Records.Record do
end
end
@spec format_as_date(DateTime.t() | NaiveDateTime.t() | Date.t()) :: String.t()
def format_as_date(dt) do
Calendar.strftime(dt, "%d/%m/%Y")
end
@@ -16,6 +16,9 @@ defmodule MusicLibrary.Records.RecordEmbedding do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(record_embedding, attrs) do
record_embedding
|> cast(attrs, [:record_id, :embedding, :text_representation])
@@ -8,4 +8,6 @@ defmodule MusicLibrary.Records.RecordRelease do
field :cover_hash, :string
field :purchased_at, :utc_datetime
end
@type t :: %__MODULE__{}
end
@@ -31,4 +31,6 @@ defmodule MusicLibrary.Records.SearchIndex do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
end
@@ -7,6 +7,17 @@ defmodule MusicLibrary.Records.SearchParser do
edge cases I haven't thought about - not an expert in parsing.
"""
@type search_result :: %{
optional(:query) => String.t(),
optional(:artist) => String.t(),
optional(:album) => String.t(),
optional(:mbid) => String.t(),
optional(:genre) => String.t(),
optional(:format) => atom(),
optional(:type) => atom(),
optional(:purchase_year) => integer()
}
import NimbleParsec
word = utf8_string([not: ?\s, not: ?,], min: 1)
@@ -99,6 +110,7 @@ defmodule MusicLibrary.Records.SearchParser do
iex> MusicLibrary.Records.SearchParser.parse("purchase_year:2024")
{:ok, %{purchase_year: 2024}}
"""
@spec parse(String.t()) :: {:ok, search_result()}
def parse(""), do: {:ok, %{query: ""}}
def parse(query) do
@@ -107,11 +119,13 @@ defmodule MusicLibrary.Records.SearchParser do
{:ok, normalize(result)}
end
@spec resolve_format(String.t()) :: atom() | nil
def resolve_format(format) do
Ecto.Enum.mappings(MusicLibrary.Records.Record, :format)
|> Enum.find_value(fn {key, value} -> if value == format, do: key end)
end
@spec resolve_type(String.t()) :: atom() | nil
def resolve_type(type) do
Ecto.Enum.mappings(MusicLibrary.Records.Record, :type)
|> Enum.find_value(fn {key, value} -> if value == type, do: key end)
+14 -13
View File
@@ -16,6 +16,12 @@ defmodule MusicLibrary.Records.Similarity do
@max_distance Application.compile_env!(:music_library, :similarity)[:max_distance]
@type find_opts :: [
limit: pos_integer(),
scope: :collection | :wishlist | nil,
max_distance: float()
]
@doc """
Generates a text representation of a record for embedding generation.
@@ -30,6 +36,7 @@ defmodule MusicLibrary.Records.Similarity do
- Last.fm community tags and similar artists (when stored)
- User-written record notes (when present)
"""
@spec text_representation(Record.t()) :: String.t()
def text_representation(%Record{} = record) do
artist_infos_map =
record.artists
@@ -231,13 +238,6 @@ defmodule MusicLibrary.Records.Similarity do
@doc """
Finds similar records based on embedding similarity.
## Options
- `:limit` - Maximum number of similar records to return (default: 10)
- `:scope` - Filter by :collection or :wishlist (default: no filter)
- `:max_distance` - Maximum cosine distance threshold (default: #{@max_distance}).
Results with distance above this are excluded.
## Examples
iex> find_similar("record-id-123", limit: 5)
@@ -246,6 +246,7 @@ defmodule MusicLibrary.Records.Similarity do
iex> find_similar("record-id-123", scope: :collection)
[%Record{}, ...]
"""
@spec find_similar(String.t(), find_opts()) :: [map()]
def find_similar(record_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
scope = Keyword.get(opts, :scope)
@@ -282,9 +283,7 @@ defmodule MusicLibrary.Records.Similarity do
end
end
@doc """
Gets the embedding for a record.
"""
@spec get_embedding(String.t()) :: {:ok, binary()} | {:error, :not_found}
def get_embedding(record_id) do
case Repo.get_by(RecordEmbedding, record_id: record_id) do
nil -> {:error, :not_found}
@@ -292,6 +291,7 @@ defmodule MusicLibrary.Records.Similarity do
end
end
@spec get_embedding_text(String.t()) :: {:ok, String.t()} | {:error, :not_found}
def get_embedding_text(record_id) do
case Repo.get_by(RecordEmbedding, record_id: record_id) do
nil -> {:error, :not_found}
@@ -299,9 +299,8 @@ defmodule MusicLibrary.Records.Similarity do
end
end
@doc """
Stores an embedding for a record.
"""
@spec store_embedding(String.t(), binary(), String.t()) ::
{:ok, RecordEmbedding.t()} | {:error, Ecto.Changeset.t()}
def store_embedding(record_id, embedding, text_representation) do
attrs = %{
record_id: record_id,
@@ -317,6 +316,7 @@ defmodule MusicLibrary.Records.Similarity do
)
end
@spec generate_embedding_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def generate_embedding_async(record) do
meta = %{title: record.title, artists: Enum.map(record.artists, & &1.name)}
params = %{record_id: record.id}
@@ -326,6 +326,7 @@ defmodule MusicLibrary.Records.Similarity do
|> Oban.insert()
end
@spec generate_all_embeddings_async() :: non_neg_integer()
def generate_all_embeddings_async do
Record
|> Repo.all()
+25 -55
View File
@@ -7,10 +7,12 @@ defmodule MusicLibrary.ScrobbleActivity do
@pagination Application.compile_env!(:music_library, :pagination)
@spec can_scrobble?() :: boolean()
def can_scrobble? do
Secrets.get("last_fm_session_key") !== nil
end
@spec scrobble_release(map(), keyword()) :: {:ok, term()} | {:error, term()}
def scrobble_release(release_with_tracks, opts) when is_list(opts) do
case Enum.sort(opts) do
[finished_at: _, started_at: _] ->
@@ -27,12 +29,14 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@spec scrobble_release(map(), {:finished_at, DateTime.t()}) :: {:ok, term()} | {:error, term()}
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
@spec scrobble_release(map(), {:started_at, DateTime.t()}) :: {:ok, term()} | {:error, term()}
def scrobble_release(release_with_tracks, {:started_at, started_at}) do
release_duration = Release.release_duration(release_with_tracks)
@@ -50,6 +54,7 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@spec scrobble_medium(integer(), map(), keyword()) :: {:ok, term()} | {:error, term()}
def scrobble_medium(number, release_with_tracks, opts) when is_list(opts) do
case Enum.sort(opts) do
[finished_at: _, started_at: _] ->
@@ -66,6 +71,8 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@spec scrobble_medium(integer(), map(), {:finished_at, DateTime.t()}) ::
{:ok, term()} | {:error, term()}
def scrobble_medium(number, release_with_tracks, {:finished_at, finished_at}) do
case find_medium(release_with_tracks, number) do
{:ok, medium} ->
@@ -78,6 +85,8 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@spec scrobble_medium(integer(), map(), {:started_at, DateTime.t()}) ::
{:ok, term()} | {:error, term()}
def scrobble_medium(number, release_with_tracks, {:started_at, started_at}) do
case find_medium(release_with_tracks, number) do
{:ok, medium} ->
@@ -100,6 +109,7 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@spec scrobble_tracks(MapSet.t(), map(), keyword()) :: {:ok, term()} | {:error, term()}
def scrobble_tracks(selected_track_ids, release_with_tracks, opts) when is_list(opts) do
case Enum.sort(opts) do
[finished_at: _, started_at: _] ->
@@ -116,6 +126,8 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@spec scrobble_tracks(MapSet.t(), map(), {:finished_at, DateTime.t()}) ::
{:ok, term()} | {:error, term()}
def scrobble_tracks(selected_track_ids, release_with_tracks, {:finished_at, finished_at}) do
all_tracks = Release.tracks(release_with_tracks)
@@ -127,6 +139,8 @@ defmodule MusicLibrary.ScrobbleActivity do
scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at})
end
@spec scrobble_tracks(MapSet.t(), map(), {:started_at, DateTime.t()}) ::
{:ok, term()} | {:error, term()}
def scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at}) do
all_tracks = Release.tracks(release_with_tracks)
@@ -187,9 +201,7 @@ defmodule MusicLibrary.ScrobbleActivity do
defp main_artist_name([]), do: nil
defp main_artist_name([artist | _rest]), do: artist.name
@doc """
Lists scrobbled tracks with pagination and search support.
"""
@spec list_tracks(map()) :: [map()]
def list_tracks(params \\ %{}) do
query = Map.get(params, :query, "")
page = Map.get(params, :page, 1)
@@ -250,23 +262,12 @@ defmodule MusicLibrary.ScrobbleActivity do
|> 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
"""
@spec count_tracks() :: non_neg_integer()
def count_tracks do
Repo.aggregate(Track, :count, :scrobbled_at_uts)
end
@doc """
Gets a single track by scrobbled_at_uts.
"""
@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
@@ -278,24 +279,19 @@ defmodule MusicLibrary.ScrobbleActivity do
end
end
@doc """
Updates a track with the given attributes.
"""
@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
@doc """
Deletes a track.
"""
@spec delete_track(LastFm.Track.t()) :: {:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
def delete_track(%Track{} = track) do
Repo.delete(track)
end
@doc """
Counts tracks matching the search query.
"""
@spec search_tracks_count(String.t()) :: non_neg_integer()
def search_tracks_count(query \\ "") do
base_query = from(t in Track)
@@ -315,15 +311,7 @@ defmodule MusicLibrary.ScrobbleActivity do
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
"""
@spec count_tracks_missing_artist_musicbrainz_id() :: non_neg_integer()
def count_tracks_missing_artist_musicbrainz_id do
query =
from t in Track,
@@ -335,15 +323,7 @@ defmodule MusicLibrary.ScrobbleActivity do
Repo.one(query) || 0
end
@doc """
Counts tracks with missing or empty album MusicBrainz IDs.
## Examples
iex> count_tracks_missing_album_musicbrainz_id()
37
"""
@spec count_tracks_missing_album_musicbrainz_id() :: non_neg_integer()
def count_tracks_missing_album_musicbrainz_id do
query =
from t in Track,
@@ -359,13 +339,8 @@ defmodule MusicLibrary.ScrobbleActivity do
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}, ...]
"""
@spec get_artists_missing_musicbrainz_id(keyword()) :: [map()]
def get_artists_missing_musicbrainz_id(opts \\ []) do
limit = Keyword.get(opts, :limit)
@@ -395,13 +370,8 @@ defmodule MusicLibrary.ScrobbleActivity do
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}, ...]
"""
@spec get_albums_missing_musicbrainz_id(keyword()) :: [map()]
def get_albums_missing_musicbrainz_id(opts \\ []) do
limit = Keyword.get(opts, :limit)
+48 -226
View File
@@ -11,15 +11,14 @@ defmodule MusicLibrary.ScrobbleRules do
alias MusicLibrary.Repo
alias MusicLibrary.ScrobbleRules.ScrobbleRule
@doc """
Returns the list of scrobble_rules.
@type list_opts :: [
type: atom(),
enabled: boolean(),
offset: non_neg_integer(),
limit: non_neg_integer()
]
## Examples
iex> list_scrobble_rules()
[%ScrobbleRule{}, ...]
"""
@spec list_scrobble_rules(list_opts()) :: [ScrobbleRule.t()]
def list_scrobble_rules(opts \\ []) do
query =
from r in ScrobbleRule,
@@ -68,15 +67,7 @@ defmodule MusicLibrary.ScrobbleRules do
Repo.all(query)
end
@doc """
Returns the count of scrobble_rules.
## Examples
iex> count_scrobble_rules()
42
"""
@spec count_scrobble_rules(list_opts()) :: non_neg_integer()
def count_scrobble_rules(opts \\ []) do
query = from(r in ScrobbleRule)
@@ -103,112 +94,41 @@ defmodule MusicLibrary.ScrobbleRules do
Repo.aggregate(query, :count)
end
@doc """
Gets a single scrobble_rule.
Raises `Ecto.NoResultsError` if the Scrobble rule does not exist.
## Examples
iex> get_scrobble_rule!(123)
%ScrobbleRule{}
iex> get_scrobble_rule!(456)
** (Ecto.NoResultsError)
"""
@spec get_scrobble_rule!(integer()) :: ScrobbleRule.t()
def get_scrobble_rule!(id), do: Repo.get!(ScrobbleRule, id)
@doc """
Creates a scrobble_rule.
## Examples
iex> create_scrobble_rule(%{field: value})
{:ok, %ScrobbleRule{}}
iex> create_scrobble_rule(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
@spec create_scrobble_rule(map()) :: {:ok, ScrobbleRule.t()} | {:error, Ecto.Changeset.t()}
def create_scrobble_rule(attrs \\ %{}) do
%ScrobbleRule{}
|> ScrobbleRule.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a scrobble_rule.
## Examples
iex> update_scrobble_rule(scrobble_rule, %{field: new_value})
{:ok, %ScrobbleRule{}}
iex> update_scrobble_rule(scrobble_rule, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
@spec update_scrobble_rule(ScrobbleRule.t(), map()) ::
{:ok, ScrobbleRule.t()} | {:error, Ecto.Changeset.t()}
def update_scrobble_rule(%ScrobbleRule{} = scrobble_rule, attrs) do
scrobble_rule
|> ScrobbleRule.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a scrobble_rule.
## Examples
iex> delete_scrobble_rule(scrobble_rule)
{:ok, %ScrobbleRule{}}
iex> delete_scrobble_rule(scrobble_rule)
{:error, %Ecto.Changeset{}}
"""
@spec delete_scrobble_rule(ScrobbleRule.t()) ::
{:ok, ScrobbleRule.t()} | {:error, Ecto.Changeset.t()}
def delete_scrobble_rule(%ScrobbleRule{} = scrobble_rule) do
Repo.delete(scrobble_rule)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking scrobble_rule changes.
## Examples
iex> change_scrobble_rule(scrobble_rule)
%Ecto.Changeset{data: %ScrobbleRule{}}
"""
@spec change_scrobble_rule(ScrobbleRule.t(), map()) :: Ecto.Changeset.t()
def change_scrobble_rule(%ScrobbleRule{} = scrobble_rule, attrs \\ %{}) do
ScrobbleRule.changeset(scrobble_rule, attrs)
end
@doc """
Returns the list of enabled scrobble_rules.
## Examples
iex> list_enabled_rules()
[%ScrobbleRule{}, ...]
"""
@spec list_enabled_rules() :: [ScrobbleRule.t()]
def list_enabled_rules do
list_scrobble_rules(enabled: true)
end
@doc """
Applies an album rule to all matching scrobbled tracks.
## Examples
iex> apply_album_rule(rule)
{:ok, 5}
iex> apply_album_rule(rule)
{:error, :invalid_rule_type}
"""
@spec apply_album_rule(ScrobbleRule.t()) :: {:ok, non_neg_integer()} | {:error, term()}
def apply_album_rule(%ScrobbleRule{type: :album} = rule) do
update_query =
from(t in Track,
@@ -231,18 +151,8 @@ defmodule MusicLibrary.ScrobbleRules do
end
end
@doc """
Applies an album rule to a specific set of scrobbled tracks.
## Examples
iex> apply_album_rule(rule, tracks)
{:ok, 3}
iex> apply_album_rule(rule, tracks)
{:error, :invalid_rule_type}
"""
@spec apply_album_rule(ScrobbleRule.t(), [LastFm.Track.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_album_rule(%ScrobbleRule{type: :album} = rule, tracks) do
track_scrobbled_at_uts = Enum.map(tracks, & &1.scrobbled_at_uts)
@@ -269,18 +179,7 @@ defmodule MusicLibrary.ScrobbleRules do
end
end
@doc """
Applies an artist rule to all matching scrobbled tracks.
## Examples
iex> apply_artist_rule(rule)
{:ok, 10}
iex> apply_artist_rule(rule)
{:error, :invalid_rule_type}
"""
@spec apply_artist_rule(ScrobbleRule.t()) :: {:ok, non_neg_integer()} | {:error, term()}
def apply_artist_rule(%ScrobbleRule{type: :artist} = rule) do
update_query =
from(t in Track,
@@ -303,18 +202,8 @@ defmodule MusicLibrary.ScrobbleRules do
end
end
@doc """
Applies an artist rule to a specific set of scrobbled tracks.
## Examples
iex> apply_artist_rule(rule, tracks)
{:ok, 7}
iex> apply_artist_rule(rule, tracks)
{:error, :invalid_rule_type}
"""
@spec apply_artist_rule(ScrobbleRule.t(), [LastFm.Track.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_artist_rule(%ScrobbleRule{type: :artist} = rule, tracks) do
track_scrobbled_at_uts = Enum.map(tracks, & &1.scrobbled_at_uts)
@@ -341,18 +230,7 @@ defmodule MusicLibrary.ScrobbleRules do
end
end
@doc """
Applies a single rule based on its type.
## Examples
iex> apply_rule(rule)
{:ok, 5}
iex> apply_rule(rule)
{:error, "Invalid rule type"}
"""
@spec apply_rule(ScrobbleRule.t()) :: {:ok, non_neg_integer()} | {:error, term()}
def apply_rule(%ScrobbleRule{type: :album} = rule) do
apply_album_rule(rule)
end
@@ -361,18 +239,8 @@ defmodule MusicLibrary.ScrobbleRules do
apply_artist_rule(rule)
end
@doc """
Applies a single rule to a specific set of tracks based on its type.
## Examples
iex> apply_rule(rule, tracks)
{:ok, 3}
iex> apply_rule(rule, tracks)
{:error, "Invalid rule type"}
"""
@spec apply_rule(ScrobbleRule.t(), [LastFm.Track.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_rule(%ScrobbleRule{type: :album} = rule, tracks) do
apply_album_rule(rule, tracks)
end
@@ -386,13 +254,9 @@ defmodule MusicLibrary.ScrobbleRules do
Uses a CASE statement to update the musicbrainz_id for all matching albums
in one database operation, which is more efficient than applying rules individually.
## Examples
iex> apply_all_album_rules([rule1, rule2])
{:ok, 15}
"""
@spec apply_all_album_rules([ScrobbleRule.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_all_album_rules([]), do: {:ok, 0}
def apply_all_album_rules(rules) when is_list(rules) do
@@ -404,13 +268,9 @@ defmodule MusicLibrary.ScrobbleRules do
Uses a CASE statement to update the musicbrainz_id for all matching albums
in one database operation, filtering by the provided tracks.
## Examples
iex> apply_all_album_rules([rule1, rule2], tracks)
{:ok, 3}
"""
@spec apply_all_album_rules([ScrobbleRule.t()], [LastFm.Track.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_all_album_rules([], _tracks), do: {:ok, 0}
def apply_all_album_rules(_rules, []), do: {:ok, 0}
@@ -423,13 +283,9 @@ defmodule MusicLibrary.ScrobbleRules do
Uses a CASE statement to update the musicbrainz_id for all matching artists
in one database operation, which is more efficient than applying rules individually.
## Examples
iex> apply_all_artist_rules([rule1, rule2])
{:ok, 25}
"""
@spec apply_all_artist_rules([ScrobbleRule.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_all_artist_rules([]), do: {:ok, 0}
def apply_all_artist_rules(rules) when is_list(rules) do
@@ -441,13 +297,9 @@ defmodule MusicLibrary.ScrobbleRules do
Uses a CASE statement to update the musicbrainz_id for all matching artists
in one database operation, filtering by the provided tracks.
## Examples
iex> apply_all_artist_rules([rule1, rule2], tracks)
{:ok, 7}
"""
@spec apply_all_artist_rules([ScrobbleRule.t()], [LastFm.Track.t()]) ::
{:ok, non_neg_integer()} | {:error, term()}
def apply_all_artist_rules([], _tracks), do: {:ok, 0}
def apply_all_artist_rules(_rules, []), do: {:ok, 0}
@@ -461,13 +313,11 @@ defmodule MusicLibrary.ScrobbleRules do
This optimized version groups rules by type and applies all rules of each type
in a single database query, which is much more efficient than applying each rule
individually.
## Examples
iex> apply_all_rules()
{:ok, [{:album, 5}, {:artist, 10}]}
"""
@spec apply_all_rules() :: [
{:ok, {atom(), String.t(), non_neg_integer()}}
| {:error, {atom(), String.t(), term()}}
]
def apply_all_rules do
:telemetry.span([:music_library, :scrobble_rules, :apply_all_rules], %{}, fn ->
enabled_rules = list_enabled_rules()
@@ -519,13 +369,11 @@ defmodule MusicLibrary.ScrobbleRules do
This optimized version groups rules by type and applies all rules of each type
in a single database query, filtering by the provided tracks.
## Examples
iex> apply_all_rules(tracks)
[{:ok, {:album, "Some Album", 5}}, {:error, {:artist, "Some Artist", "reason"}}]
"""
@spec apply_all_rules([LastFm.Track.t()]) :: [
{:ok, {atom(), String.t(), non_neg_integer()}}
| {:error, {atom(), String.t(), term()}}
]
def apply_all_rules([]) do
list_enabled_rules()
|> Enum.map(fn rule ->
@@ -575,15 +423,7 @@ defmodule MusicLibrary.ScrobbleRules do
end)
end
@doc """
Counts how many tracks would be affected by an album rule.
## Examples
iex> count_album_matches(rule)
5
"""
@spec count_album_matches(ScrobbleRule.t()) :: non_neg_integer()
def count_album_matches(%ScrobbleRule{type: :album} = rule) do
query =
from(t in Track,
@@ -594,15 +434,7 @@ defmodule MusicLibrary.ScrobbleRules do
Repo.one(query) || 0
end
@doc """
Counts how many tracks would be affected by an artist rule.
## Examples
iex> count_artist_matches(rule)
10
"""
@spec count_artist_matches(ScrobbleRule.t()) :: non_neg_integer()
def count_artist_matches(%ScrobbleRule{type: :artist} = rule) do
query =
from(t in Track,
@@ -613,15 +445,7 @@ defmodule MusicLibrary.ScrobbleRules do
Repo.one(query) || 0
end
@doc """
Counts how many tracks would be affected by a rule.
## Examples
iex> count_rule_matches(rule)
5
"""
@spec count_rule_matches(ScrobbleRule.t()) :: non_neg_integer()
def count_rule_matches(%ScrobbleRule{type: :album} = rule) do
count_album_matches(rule)
end
@@ -635,13 +459,11 @@ defmodule MusicLibrary.ScrobbleRules do
Takes a list of results from rule application and logs summary statistics
and any errors that occurred.
## Examples
iex> log_apply_results([{:ok, {:album, "Album", 5}}, {:error, {:artist, "Artist", "reason"}}])
:ok
"""
@spec log_apply_results([
{:ok, {atom(), String.t(), non_neg_integer()}}
| {:error, {atom(), String.t(), term()}}
]) :: :ok
def log_apply_results(results) do
{applied, errors} =
Enum.split_with(results, fn
@@ -25,6 +25,7 @@ defmodule MusicLibrary.ScrobbleRules.ScrobbleRule do
end
@doc false
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(scrobble_rule, attrs) do
scrobble_rule
|> cast(attrs, [:type, :match_value, :target_musicbrainz_id, :enabled, :description])
+9 -3
View File
@@ -16,6 +16,8 @@ defmodule MusicLibrary.Search do
@pagination Application.compile_env!(:music_library, :pagination)
@type search_opts :: [limit: non_neg_integer()]
@doc """
Performs a universal search across all entity types.
@@ -23,10 +25,8 @@ defmodule MusicLibrary.Search do
- :collection - Records in the collection
- :wishlist - Records in the wishlist
- :artists - Artists
Options:
- :limit - Limit per category (default: 5)
"""
@spec universal_search(String.t(), search_opts()) :: map()
def universal_search(query, opts \\ []) do
limit = Keyword.get(opts, :limit, @pagination[:search_preview_limit])
@@ -41,6 +41,7 @@ defmodule MusicLibrary.Search do
@doc """
Searches records in the collection (purchased records).
"""
@spec search_collection(String.t(), non_neg_integer()) :: [map()]
def search_collection(query, limit \\ @pagination[:search_preview_limit]) do
Collection.search_records(query, limit: limit)
end
@@ -48,6 +49,7 @@ defmodule MusicLibrary.Search do
@doc """
Searches records in the wishlist (unpurchased records).
"""
@spec search_wishlist(String.t(), non_neg_integer()) :: [map()]
def search_wishlist(query, limit \\ @pagination[:search_preview_limit]) do
Wishlist.search_records(query, limit: limit)
end
@@ -58,6 +60,7 @@ defmodule MusicLibrary.Search do
Searches across artist names in the artist_records table,
returning distinct artists that match the query.
"""
@spec search_artists(String.t(), non_neg_integer()) :: [map()]
def search_artists(query, limit \\ @pagination[:search_preview_limit]) do
case String.trim(query) do
"" ->
@@ -89,6 +92,7 @@ defmodule MusicLibrary.Search do
- :wishlist_count
- :artists_count
"""
@spec search_counts(String.t()) :: map()
def search_counts(query) do
%{
collection_count: Collection.search_records_count(query),
@@ -101,6 +105,7 @@ defmodule MusicLibrary.Search do
@doc """
Searches record sets by name, description, and contained records.
"""
@spec search_record_sets(String.t(), non_neg_integer()) :: [map()]
def search_record_sets(query, limit \\ @pagination[:search_preview_limit]) do
RecordSets.search_record_sets(query, limit: limit)
end
@@ -108,6 +113,7 @@ defmodule MusicLibrary.Search do
@doc """
Gets the count of artists matching the search query.
"""
@spec search_artists_count(String.t()) :: non_neg_integer()
def search_artists_count(query) do
case String.trim(query) do
"" ->
+3
View File
@@ -2,16 +2,19 @@ defmodule MusicLibrary.Secrets do
alias MusicLibrary.Repo
alias MusicLibrary.Secrets.Secret
@spec store(String.t(), String.t()) :: {:ok, Secret.t()} | {:error, Ecto.Changeset.t()}
def store(name, value) do
%Secret{}
|> Secret.changeset(%{name: name, value: value})
|> Repo.insert(on_conflict: :replace_all)
end
@spec get!(String.t()) :: Secret.t()
def get!(name) do
Repo.get!(Secret, name)
end
@spec get(String.t()) :: Secret.t() | nil
def get(name) do
Repo.get(Secret, name)
end
+3
View File
@@ -10,6 +10,9 @@ defmodule MusicLibrary.Secrets.Secret do
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(secret, attrs) do
secret
|> cast(attrs, [:name, :value])
+9
View File
@@ -0,0 +1,9 @@
defmodule MusicLibrary.Types do
@moduledoc false
@type pagination_opts :: [
limit: non_neg_integer(),
offset: non_neg_integer(),
order: atom()
]
end
+4
View File
@@ -7,6 +7,7 @@ defmodule MusicLibrary.Wishlist do
@pagination Application.compile_env!(:music_library, :pagination)
@spec search_records(String.t(), MusicLibrary.Types.pagination_opts()) :: [SearchIndex.t()]
def search_records(query, opts \\ []) do
limit = Keyword.get(opts, :limit, @pagination[:default_page_size])
offset = Keyword.get(opts, :offset, 0)
@@ -15,14 +16,17 @@ defmodule MusicLibrary.Wishlist do
Records.search_records(base_search(), query, limit: limit, offset: offset, order: order)
end
@spec search_records_count(String.t()) :: non_neg_integer()
def search_records_count(query) do
Records.search_records_count(base_search(), query)
end
@spec count() :: non_neg_integer()
def count do
Repo.aggregate(base_search(), :count)
end
@spec wishlisted_releases_query() :: Ecto.Query.t()
def wishlisted_releases_query do
from rr in RecordRelease,
where: is_nil(rr.purchased_at),
+3
View File
@@ -5,10 +5,12 @@ defmodule MusicLibraryWeb.Auth do
import Phoenix.Controller, only: [redirect: 2]
import Plug.Conn
@spec correct_login_password?(String.t()) :: boolean()
def correct_login_password?(password) do
Plug.Crypto.secure_compare(login_password(), password)
end
@spec require_api_token(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def require_api_token(conn, _opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
true <- Plug.Crypto.secure_compare(api_token(), token) do
@@ -31,6 +33,7 @@ defmodule MusicLibraryWeb.Auth do
|> Keyword.fetch!(:api_token)
end
@spec require_logged_in(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def require_logged_in(conn, _opts) do
if get_session(conn, :logged_in) do
conn
@@ -162,6 +162,7 @@ defmodule MusicLibraryWeb.Components.Pagination do
"""
end
@spec page_to_offset(pos_integer(), pos_integer()) :: non_neg_integer()
def page_to_offset(page, per_page) do
(page - 1) * per_page
end
+1
View File
@@ -21,6 +21,7 @@ defmodule MusicLibraryWeb.Duration do
"0:00"
"""
@spec format_duration(non_neg_integer()) :: String.t()
def format_duration(milliseconds) do
milliseconds
|> System.convert_time_unit(:millisecond, :second)
+2
View File
@@ -9,6 +9,8 @@ defmodule MusicLibraryWeb.ErrorMessages do
use Gettext, backend: MusicLibraryWeb.Gettext
@spec friendly_message(term()) :: String.t()
# App error atoms
def friendly_message(:cover_not_available), do: gettext("cover art is not available")
def friendly_message(:no_duration), do: gettext("release has no track duration information")
@@ -1,6 +1,7 @@
defmodule MusicLibraryWeb.LiveHelpers.Params do
@pagination Application.compile_env!(:music_library, :pagination)
@spec parse_page(String.t() | nil | term()) :: pos_integer()
def parse_page(nil), do: 1
def parse_page(page) when is_binary(page) do
@@ -12,6 +13,8 @@ defmodule MusicLibraryWeb.LiveHelpers.Params do
def parse_page(_), do: 1
@spec parse_page_size(String.t() | nil | term(), [pos_integer()] | nil, pos_integer()) ::
pos_integer()
def parse_page_size(nil, _allowed, default), do: default
def parse_page_size(page_size, allowed, default) when is_binary(page_size) do
@@ -26,6 +29,7 @@ defmodule MusicLibraryWeb.LiveHelpers.Params do
def parse_page_size(_, _allowed, default), do: default
@spec merge_pagination(map(), map(), non_neg_integer(), keyword()) :: map()
def merge_pagination(params, url_params, total_entries, opts \\ []) do
allowed = Keyword.get(opts, :allowed_page_sizes)
default_page_size = params[:page_size] || @pagination[:default_page_size]
@@ -39,14 +43,18 @@ defmodule MusicLibraryWeb.LiveHelpers.Params do
|> Map.put(:total_entries, total_entries)
end
@spec merge_query(map(), String.t() | nil) :: map()
def merge_query(params, query) do
Map.put(params, :query, query)
end
@spec merge_order(map(), atom()) :: map()
def merge_order(params, order) do
Map.put(params, :order, order)
end
@spec apply_fallback_index(Phoenix.LiveView.Socket.t(), map(), atom(), function()) ::
Phoenix.LiveView.Socket.t()
def apply_fallback_index(socket, params, stream_key, apply_action_fn) do
if get_in(socket.assigns, [:streams, stream_key]) == nil do
apply_action_fn.(socket, :index, params)
+2
View File
@@ -12,6 +12,7 @@ defmodule MusicLibraryWeb.Markdown do
Double square brackets are converted to search links before the markdown is processed.
"""
@spec to_html(String.t() | nil) :: String.t()
def to_html(markdown_text) when is_binary(markdown_text) do
markdown_text
|> process_double_bracket_links()
@@ -41,6 +42,7 @@ defmodule MusicLibraryWeb.Markdown do
iex> MusicLibraryWeb.Markdown.process_double_bracket_links(~s|[[genre:"psychedelic rock"]]|)
~s|[psychedelic rock](/collection?query=genre%3A%22psychedelic+rock%22)|
"""
@spec process_double_bracket_links(String.t()) :: String.t()
def process_double_bracket_links(text) do
~r/\[\[([^\]]+)\]\]/
|> Regex.replace(text, fn _match, content ->
+10
View File
@@ -1,10 +1,19 @@
defmodule OpenAI do
alias OpenAI.API
@type chat_stream_opts :: [
model: String.t(),
temperature: float(),
instructions: String.t(),
on_chunk: (String.t() -> any())
]
@spec gpt(OpenAI.Completion.t()) :: {:ok, map()} | {:error, term()}
def gpt(completion) do
API.gpt(completion, config())
end
@spec chat_stream([map()], chat_stream_opts()) :: :ok | {:error, String.t()}
def chat_stream(messages, opts \\ []) when is_list(messages) do
model = Keyword.get(opts, :model, "gpt-4.1")
temperature = Keyword.get(opts, :temperature, 0.7)
@@ -17,6 +26,7 @@ defmodule OpenAI do
end
end
@spec embeddings(String.t()) :: {:ok, [float()]} | {:error, term()}
def embeddings(text) do
API.get_embeddings(text, config())
end
+5
View File
@@ -1,6 +1,7 @@
defmodule OpenAI.API do
require Logger
@spec gpt(OpenAI.Completion.t(), OpenAI.Config.t()) :: {:ok, map()} | {:error, term()}
def gpt(completion, config) do
resp =
config
@@ -26,6 +27,9 @@ defmodule OpenAI.API do
end
end
@spec chat_stream([map()], String.t(), String.t(), float(), OpenAI.Config.t(), (String.t() ->
any())) ::
:ok | {:error, String.t()}
def chat_stream(messages, instructions, model, temperature, config, cb) do
case config
|> new_request()
@@ -60,6 +64,7 @@ defmodule OpenAI.API do
end
end
@spec get_embeddings(String.t(), OpenAI.Config.t()) :: {:ok, [float()]} | {:error, term()}
def get_embeddings(text, config) do
resp =
config
+7
View File
@@ -4,4 +4,11 @@ defmodule OpenAI.Completion do
temperature: 0.2,
role: "user",
model: "gpt-4o-mini"
@type t :: %__MODULE__{
content: String.t(),
temperature: float(),
role: String.t(),
model: String.t()
}
end
+4
View File
@@ -20,6 +20,9 @@ defmodule Req.RateLimiter do
Creates the ETS table used to track request timestamps.
Call once at application startup.
"""
@type attach_opts :: [name: atom(), cooldown: non_neg_integer()]
@spec new() :: :ets.table()
def new do
:ets.new(@table, [:set, :public, :named_table])
end
@@ -33,6 +36,7 @@ defmodule Req.RateLimiter do
* `:cooldown` - minimum milliseconds between requests
"""
@spec attach(Req.Request.t(), attach_opts()) :: Req.Request.t()
def attach(request, opts) do
name = Keyword.fetch!(opts, :name)
cooldown = Keyword.fetch!(opts, :cooldown)
+8
View File
@@ -4,16 +4,24 @@ defmodule SqliteVec.Ecto.Float32 do
"""
use Ecto.Type
@impl true
@spec type() :: :binary
def type, do: :binary
@impl true
@spec cast(any()) :: {:ok, SqliteVec.Float32.t()}
def cast(value) do
{:ok, SqliteVec.Float32.new(value)}
end
@impl true
@spec load(binary()) :: {:ok, SqliteVec.Float32.t()}
def load(data) do
{:ok, SqliteVec.Float32.from_binary(data)}
end
@impl true
@spec dump(SqliteVec.Float32.t()) :: {:ok, binary()} | :error
def dump(%SqliteVec.Float32{} = vector) do
{:ok, SqliteVec.Float32.to_binary(vector)}
end
+3
View File
@@ -75,6 +75,9 @@ defmodule SqliteVec.Ecto.Query do
end
end
@doc """
Matches a vector against a virtual table using the `match` operator.
"""
defmacro vec_match(a, b) do
quote do
fragment("? match ?", unquote(a), unquote(b))
+4
View File
@@ -41,6 +41,7 @@ defmodule SqliteVec.Float32 do
iex> SqliteVec.Float32.new(Nx.tensor([1, 2], type: :f32))
%SqliteVec.Float32{data: <<1.0::float-32-native, 2.0::float-32-native>>}
"""
@spec new(t() | [number()]) :: t()
def new(vector_or_list_or_tensor)
def new(%SqliteVec.Float32{} = vector) do
@@ -74,6 +75,7 @@ defmodule SqliteVec.Float32 do
@doc """
Creates a new vector from its binary representation
"""
@spec from_binary(binary()) :: t()
def from_binary(binary) when is_binary(binary) do
%SqliteVec.Float32{data: binary}
end
@@ -81,6 +83,7 @@ defmodule SqliteVec.Float32 do
@doc """
Converts the vector to its binary representation
"""
@spec to_binary(t()) :: binary()
def to_binary(vector) when is_struct(vector, SqliteVec.Float32) do
vector.data
end
@@ -88,6 +91,7 @@ defmodule SqliteVec.Float32 do
@doc """
Converts the vector to a list
"""
@spec to_list(t()) :: [float()]
def to_list(vector) when is_struct(vector, SqliteVec.Float32) do
<<bin::binary>> = vector.data
+1
View File
@@ -1,6 +1,7 @@
defmodule Wikipedia do
alias Wikipedia.API
@spec get_artist_summary(String.t()) :: {:ok, map()} | {:error, :no_english_wikipedia | term()}
def get_artist_summary(wikidata_id) do
config = wikipedia_config()
+5
View File
@@ -5,6 +5,8 @@ defmodule Wikipedia.API do
require Logger
@spec get_wikipedia_title(String.t(), Wikipedia.Config.t()) ::
{:ok, String.t() | nil} | {:error, term()}
def get_wikipedia_title(wikidata_id, config) do
request =
Req.new(
@@ -41,6 +43,7 @@ defmodule Wikipedia.API do
end
end
@spec get_article_summary(String.t(), Wikipedia.Config.t()) :: {:ok, map()} | {:error, term()}
def get_article_summary(title, config) do
request =
Req.new(
@@ -65,6 +68,8 @@ defmodule Wikipedia.API do
end
end
@spec get_article_extract(String.t(), Wikipedia.Config.t()) ::
{:ok, String.t() | nil} | {:error, term()}
def get_article_extract(title, config) do
request =
Req.new(