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