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