MyAnimeList

A MyAnimeList API v2 client for Elixir, covering both anime and manga. The media type is the first argument on every call.

def deps do
[{:myanimelist, "~> 0.1"}]
end

Public data — just a client id

Search and metadata authenticate with only your app's client_id (the X-MAL-CLIENT-ID header), no user login:

MyAnimeList.search(:anime, "frieren", client_id)
#=> {:ok, [%{id: 52991, title: "Sousou no Frieren", cover_url: "…", score: 8.9, media_type: "tv", year: 2023}, …]}
{:ok, manga} = MyAnimeList.get(:manga, 2, client_id)
manga.num_chapters #=> 162 (anime results carry num_episodes/studios instead)

Details come back normalized with a stable shape across both media — the other media's fields are present as nil/[].

OAuth login

MAL uses authorization-code + PKCE (plain challenge). Wrap your app registration in a MyAnimeList.Client:

client = %MyAnimeList.Client{
client_id: System.fetch_env!("MAL_CLIENT_ID"),
client_secret: System.fetch_env!("MAL_CLIENT_SECRET"),
redirect_uri: "https://example.com/auth/mal/callback"
}
verifier = MyAnimeList.new_code_verifier() # stash in the session
url = MyAnimeList.authorize_url(client, state, verifier) # redirect the user here
# in your callback:
{:ok, token} = MyAnimeList.exchange_code(client, code, verifier)
#=> %MyAnimeList.Token{access_token: "…", refresh_token: "…", expires_at: ~U[…]}

Keep it fresh before an authenticated call:

{:ok, token} = MyAnimeList.ensure_fresh(client, token) # refreshes if near expiry

List sync — an access token

{:ok, statuses} = MyAnimeList.list_statuses(:anime, token.access_token)
#=> %{52991 => %{"status" => "watching", "score" => 9, "num_episodes_watched" => 12}, …}
MyAnimeList.update_list_status(:manga, token.access_token, 2,
%{status: "reading", num_chapters_read: 100})
MyAnimeList.my_progress(:anime, token.access_token, 52991) #=> {:ok, 12}

progress_key/1 and statuses/1 give you the media-correct field name and the valid status vocabulary when building an update:

MyAnimeList.progress_key(:anime) #=> "num_episodes_watched"
MyAnimeList.statuses(:manga) #=> ["reading", "completed", "on_hold", "dropped", "plan_to_read"]

Persisting tokens

MyAnimeList ships no schema and no database dependency. Implement the MyAnimeList.TokenStore behaviour against your own storage:

defmodule MyApp.MALTokens do
@behaviour MyAnimeList.TokenStore
@impl true
def get_token(user_id), do: # → %MyAnimeList.Token{} | nil
@impl true
def put_token(user_id, token), do: # persist, → :ok
@impl true
def delete_token(user_id), do: :ok
end

Errors & testing

Every function returns values and never raises. Failures come back as an {:error, reason} tuple, where reason is one of :not_found, :unauthorized, :not_configured, a :http/status pair, or a :transport/exception pair. Every request merges req_options, so point it at Req.Test:

# config/test.exs
config :myanimelist, req_options: [plug: {Req.Test, MyAnimeList}]

License

MIT © Alexander Don