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