Endpoints consumed by animu-api, the client method that wraps each one,
behavior at the boundary, and a usage example per method.
Conventions
- All track timings are milliseconds (epoch ms via
timestart, durations in ms). Verified station-side:timestart + durationlands within ~1–3 s of the next track's start. - Malformed payloads degrade instead of throwing whenever the server has no contract (see each endpoint).
- Every method throws
AnimuApiErroron network/HTTP failure (statusCode: 0for network) unless marked otherwise; client-side input problems throwValidationError.
GET https://api.animu.moe/ → { track: Track | null, listeners: Listeners }
One-shot payload (no auth). Serves the same status file the SSE stream broadcasts (see Realtime).
Mapping rules:
parseNowPlayingTitle(rawtitle)splits"Artist - Title | Anime"→Track.{title,artist,anime}. Server-resolvedtrack.artist/track.titlewin when present; therawtitleparse is the fallback.- If the server title still embeds
" | "(itstitleBreakeronly splits on" - "), therawtitleparse wins so the anime suffix is not swallowed into the title. Track.id=track.playlist.track_id("0"when absent);Track.playlistName=track.playlist.title(""when absent).isRequest=rawtitlecontainspedido(case-insensitive).- Listener aliases tried in order:
listeners→currentListeners→active_listeners→total; invalid values clamp to0. trackisnullwhen the payload omits it.startTime=new Date(timestart), falls back toDate.now()on0.- Artwork quality chain (
high: large→medium→tiny /medium: medium→tiny /low: tiny /off: default cover); the resolved URL must end in an image extension, else the default cover.
const { track, listeners } = await animu.getStreamMetadata();
console.log(track?.title, track?.anime, listeners.value);GET https://www.animu.moe/teste/locutor.php → Program
Every field degrades to "" on malformed input — never fails validation.
Program.isLiveistrueonly whenlocutoris non-empty and not the AutoDJ persona (haruka yuki/haru). Otherwisedjis normalized to"Haruka Yuki".acceptingRequestsistrueunlesspedidos_ao_vivo === "no"(exact match).
const p = await animu.getProgram();
p.isLive ? `ON AIR: ${p.dj}` : "AutoDJ", p.acceptingRequests;GET …/teste/ultimasmusicas_json.php (played) · GET …/teste/ultimospedidos_json.php (requests) → Track[]
Rows are positional PHP arrays.
- played:
[rawtitle, coverUrl] - requests:
[rawtitle, "HH:MM:SS", requestId, coverUrl]
Rules:
- Filler rows (jingles/idents/transitions) are dropped via
isRealTrack: rawtitle containsanimu, artist containsrádio animu, or anime containspassagem. Empty titles too. - One malformed row invalidates the whole payload →
[](station jingle rows legitimately violate the shape). - Rows keep payload order (newest-first);
duration: 0,id: "-1"(played),isRequest: trueon rows. - Request timestamps are the station wall clock — São Paulo, fixed UTC-3 since 2019. Anchored to the São Paulo calendar date of
now(a future instant ⇒ the day rolled over ⇒ the row belongs to yesterday), so display localizes correctly anywhere.
const history = await animu.getTrackHistory("played");
history[0]; // newest on-real-track entry (.title/.artist/.anime parsed from newest rawtitle)GET https://www.animu.moe/teste/requestSearchTest.php → MusicRequestPagination
Query: server, query, filter?, requestable?, limit? (default 25), offset? (default 0). Tastypie envelope: meta.{limit,offset,total_count,next,previous} + objects[].
- Row title format:
Artist-Title|Anime(no spaces); without a-the whole segment becomes both artist and song (documented quirk). requestable=!timestrike.image_*are relative paths, prefixed with the web base.nextPageParamsparsed frommeta.next, reusable assearchMusicinput;totalPages=ceil(total_count / limit).
const page = await animu.searchMusic({ server: 1, query: "Gurenge" });
if (page.totalResults) {
await animu.submitMusicRequest({
trackId: page.results[0].id, // .song/.anime/.artist/.requestable available
message: "bora",
sessionId,
});
}POST …/teste/sistemaPedidos/pedirquatro.php (multipart: allmusic, message?, PHPSESSID) → RequestResult
The endpoint adopts PHPSESSID from the body for every client, so no
client-type query flag is sent. Client identity travels in the X-Client-*
headers / User-Agent built from clientInfo.
- Empty body = success.
erro: false→PANEL_UNAVAILABLE.- Block keys → codes:
PEDIBLOCK(detail = UTC datetime,"…Z"),ANIBLOCK,ARTISTBLOCK,COVERBLOCK. - Unknown JSON →
REQUEST_ERROR; non-JSON body echoed verbatim as the error.
Full result code set: PEDIBLOCK, ANIBLOCK, ARTISTBLOCK, COVERBLOCK, HARUBLOCK, STRIKE_AND_OUT, ONAIR, BLOCOBLOCK, NOLOGIN, NO2FA, PANEL_UNAVAILABLE, REQUEST_ERROR — business errors come back as data, never thrown.
POST …/paineldj/ajaxforms(defasado)/request/salvar.php (multipart) → boolean
- Client validation before any network call:
name,city,artist,music,animerequired and ≤ 100 chars;request?≤ 500 chars → throwsValidationError. - Response
trueiff body is"1"; everything else (incl. network failure) →false.
await animu.submitLiveRequest({ name, city, artist, music, anime, request });GET https://stream.animu.moe/?json=1 → Stream[] ({ id, bitrate, category, url })
Non-empty list is trusted and cached for the instance lifetime; failures return the fallback relays (320/192/64). forceRefresh bypasses both instance cache and the HTTP micro-cache.
const relays = await animu.getStreams();
player.src = relays[0].url; // e.g. "https://stream.animu.moe/320"validateSession(PHPSESSID)GET …/teste/chatIsThisReal.php?PHPSESSID=…→trueiff body"1"; failures returnfalse.logout(PHPSESSID)GET …/teste/byeChat.php?PHPSESSID=…→ void, best-effort, never throws.exchangeToken({code, redirectUri, codeVerifier})POST …/teste/exchange-token.php(urlencoded; server performs the Discord PKCE exchange) →UserwithsessionId. A servererrorfield throwsAnimuApiErrorcarrying the HTTP status.
Prefer the modern animu.auth client (Auth API) over these.
GET https://api.animu.moe/tungtungtung/ — text/event-stream, no auth.
Client: animu.live (AnimuLive), SSEDecoder.
Backed by the Go rewrite-animu-api
daemon: the metadata daemon polls upstream at 1 Hz, writes a status file,
and the SSE process broadcasts every on-air change. Compared with 1 Hz API
polling (verified over a 15-minute side-by-side capture), the stream delivers
the same fields and strictly more fidelity: it also broadcasts
sub-second listener ticks a 1 s poller can miss.
Events
| Event | Payload | Meaning |
|---|---|---|
song_change |
see below | Seeds new clients on connect; re-sent on every rawtitle change |
listeners |
{ "listeners": n } |
Count change (also piggybacked inside song_change) |
// song_change
{
"server_name": "Animu FM Radio Station - The Most Moe Radio of Brazil!",
"status": "autodj", // "autodj" | "live" | "offline"
"offline_since": "2026-09-17T18:00:00Z?", // RFC3339, only while offline
"message": "…", // station banner, only while offline
"rawtitle": "Artist - Title | Anime",
"listeners": 27,
"track": { …as section 1, plus "album"; "duration" may be "notime" → coerced to 0 }
}Timing semantics (verified against real traffic):
track.timestart— epoch ms;Track.startTime= that instant. Station-accurate: consecutive starts land ~1–3 s apart from the previous track's stated end (upstream RadioBoss drift, not a unit bug). This is the station-side timestamp of a song — event payloads carry no server clock forlisteners; every mapped event exposests, the client arrival time in epoch ms (replayed inbox events keep their original stamp).track.duration— ms. Live DJ blocks marked[NO AR]have no resolvable length: the daemon sends"notime", which degrades toduration: 0⇒getTrackProgress()reportsnull(intended).status:"autodj"(DJ-name match),"live"(human DJ),"offline"(station down after 5 consecutive failed upstream polls → placeholder track: artist"Rádio Animu", title"Offline — Voltamos já!", no-cover artwork, zeroed duration/listeners, plusoffline_since+message).
// Callbacks
const stop = animu.live.subscribe({
onOpen: () => {},
onSongChange: ({ track, listeners, status }) => console.log(track?.title, listeners.value),
onListeners: (listeners, at) => console.log(at, listeners.value),
onError: console.warn, // transport / HTTP / validation — auto-reconnects
onClose: () => {},
});
// later
stop.close();
// Ordered queue consumption — every event carries `ts` (epoch ms, arrival;
// inbox replays keep their original stamp; the authoritative station-side
// timestamp of a song is `song.track.startTime`)
for await (const event of animu.live.events(signal)) {
if (event.type === "song_change") render(event.song, event.ts);
if (event.type === "listeners") updateCounter(event.listeners.value, event.ts);
}Inbox (ordered replay queue for clients). One shared connection fans out to
every subscriber; it opens with the first subscriber and closes with the last —
loop in events() for a queue, or attach handlers. The connection retains the
last inboxSize events (default 128): a late/re-subscriber drains that backlog
in order before live events arrive. Consecutive listeners updates
coalesce to the newest value; past the cap the oldest events drop.
inboxSize: 0 disables it (late subscribers only get the last known state).
Per events() consumer the pending queue is bounded by maxPending (default
120) with the same coalesce/drop policy.
Delivery guarantees:
- The server is best-effort (per-client channel of 16, silently skips slow clients) and sends no keep-alive comments; both are covered client-side by the seeding behavior + automatic reconnect with capped backoff and jitter. Non-retryable: HTTP 4xx other than 408/429.
song_changeis authoritative — after any gap the next event reflects current state.onListenersfires deduped (only when the value changed vs the previous event).- Late subscribers replay the inbox synchronously, then go live; the very first event on a fresh connect is always the seeded
song_change. SSEDecoderis exported for custom transports;message-type events (noevent:field) are treated assong_change.- Long-lived by design — no request timeout. Requires a streaming-capable fetch: browsers, Node ≥ 18, Deno, Bun natively;
expo/fetchin React Native (global RN fetch can't stream bodies).
| Feature | Value |
|---|---|
| User-Agent | animu-api, or derived from clientInfo |
| Client headers | X-Client-* from clientInfo (native only) |
| Timeout | 20 s per request (AbortController) |
| GET micro-cache | 2.5 s per URL; bypass with noCache / forceRefresh |
| JSON parsing | falls back to raw text for non-JSON bodies |
| Errors | AnimuApiError (statusCode: 0 for network/timeout) |
The Animu Login System
replaces the legacy Discord flow with multi-provider OAuth (Discord, Google,
Apple, Fluxer), a passwordless Animu Connect email-code layer and full
profile management. AnimuAuth is the client for that service.
import { AnimuAuth } from "animu-api";
const auth = new AnimuAuth(); // production deploy
// new AnimuAuth({ baseUrl: "http://localhost:8088" })
// new AnimuApi({ authBaseUrl }).auth // via AnimuApiDefault baseUrl https://www.animu.moe/teste/login_system_project; requests target <baseUrl>/api/v5/….
{ "ok": true, "data": { /* endpoint-specific */ } }
{ "ok": false, "error": { "code": "unauthenticated", "message": "no valid session" } }Server failures throw AnimuApiError with .statusCode and .code; network/timeout failures have statusCode: 0, no code.
| HTTP | code |
Meaning |
|---|---|---|
| 400 | missing_params |
exchange-token missing params; unlink missing provider |
| 400 | invalid_request |
Malformed email on code requests; extra email already set on emails.php POST |
| 400 | invalid_upload |
Avatar missing/unsupported/too large |
| 400 | link_failed / unlink_failed |
Link/unlink rejected |
| 401 | token_exchange_failed / provider_error |
Provider rejected the code |
| 401 | email_code_failed |
Wrong/expired Animu Connect email code, or too many attempts |
| 401 | unauthenticated |
No valid session (also thrown client-side without token) |
| 403 | forbidden_origin |
CSRF guard (cookie-authenticated cross-origin) |
| 404 | unknown_provider / no_avatar / no_banner |
— |
| 404 | not_found |
emails.php DELETE: no removable (extra) email with that id |
| 409 | email_taken |
Email already belongs to another account (or yours, as a provider email) |
| 409 | refresh_failed |
The refresh itself could not run (a single provider outage is reported in updated, not thrown) |
| 409 | link_conflict |
Provider identity already belongs to another profile |
| 409 | last_provider |
Unlinking would leave no social provider (the Animu Connect email is a login method, not a provider) |
| 422 | avatar_nsfw |
Safety filter |
The session token is the PHPSESSID returned at login. Logins store it on the
client automatically; session methods accept an explicit sessionId override.
Sent as the X-Session-Id header.
auth.sessionToken; // string | null
auth.setSessionToken(token); // rehydrate
await auth.clearSession(); // logout + drop localsRules: sessions live 7 days (idle-expired at 7 days), the id is regenerated on
login (fixation protection). user.verified === true only when Discord is
linked and Discord 2FA is on — that gates the music-request queue;
Google/Apple never verify. Identity precedence: custom user edits > Discord (when linked) > first provider — custom names/avatars are never overwritten.
Returns ProviderInfo[] ({ name, label }); build login buttons from it — the server can add/remove providers without a client release.
exchangeToken(params) — POST /api/v5/auth/exchange-token.php → AuthSession { sessionToken, action, user }
action: "registered" | "login". Primary mobile login.
provider(defaultdiscord)code— authorization code, or native GoogleserverAuthCode; required unlessidentityTokenidentityToken— native Apple RS256id_token(alternative tocode)redirectUri— required for every flow except native Google Sign-IncodeVerifier— PKCEname/firstName/lastName— AppleidentityTokenonly (Apple reports the name on first consent only)
// Native Google Sign-In (serverAuthCode): no redirectUri, no PKCE.
await auth.exchangeToken({ provider: "google", code: serverAuthCode });
// Native Sign in with Apple (RS256 id_token; verified against Apple JWKS).
await auth.exchangeToken({
provider: "apple",
identityToken,
name: fullName, // optional
});The server finishes the login by re-fetching every linked provider (the same
path refreshProfile takes), so user already carries fresh
name/handle/avatar/verified — no follow-up refreshProfile() call is needed.
The refresh is best-effort: a provider outage never fails the login.
Animu Connect: emails a single-use 4-digit login code (TTL 600 s, 5 attempts, 60 s resend cooldown). The answer is always generic — a code is only sent when the address belongs to an account (no email enumeration). Provider emails are auto-registered at login/link, so every Google/Apple/etc. login works here with no extra setup.
Verifies the code and starts a session (same response as exchangeToken).
Errors: 400 invalid_request (malformed email), 401 email_code_failed
(wrong/expired code or too many attempts). Like exchangeToken, the server
refreshes all linked providers before returning, so user is already fresh.
getSessionStatus(sessionId?) — GET /api/v5/auth/session-status.php → { authenticated, sessionToken }
logout(sessionId?) — POST /api/v5/auth/logout.php → destroys the session server-side, clears the stored token.
{ user, banner, linkedProviders, availableProviders, session, links }; notable fields: user.avatarUrl (relative paths resolved against baseUrl), banner.color (accent fallback), session.loginProvider (discord|google|fluxer|apple|animu, "animu" = email-code login).
Re-fetches every linked provider and re-evaluates verified; also reconciles
missing provider-email Animu Connect rows.
Cached media is preserved on a transient provider/CDN failure: avatar/banner bytes are only replaced when the provider actually returns new media, while the accent colour is refreshed even when the provider omits the banner. Discord-owned media is re-cached even when Google/Apple owns the current identity source, and one provider being down is reported per-provider without failing the refresh.
The account's Animu Connect emails: provider emails (auto-registered,
source: "provider", not removable) plus the optional extra source: "animu"
one — there is always at most one extra email.
Emails a code to add the ONE extra email; refused with 400 invalid_request
while an extra email exists (remove it first). 409 email_taken when the
address already belongs to any account (including your own provider emails).
Verifies the code and stores the address as the account's extra Animu Connect
email (replaced in place; at most one is guaranteed). 401 email_code_failed,
409 email_taken.
Only the extra source: "animu" email is removable; provider emails answer
404 not_found.
Same provider params as exchangeToken (plus optional user for the Apple form_post callback), authenticated by session. Run the OAuth redirect yourself, then post the code.
unlinkProvider(provider) — POST /api/v5/me/unlink.php → { unlinked, provider, needsSetup, linkedProviders }
At least one social provider must remain — the Animu Connect email is a
login method, not a provider. needsSetup reaches true when the account lost its identity.
await auth.getAvatar(); // GET → { bytes, contentType }
await auth.uploadAvatar({ avatar, filename? }); // POST, jpeg/png/webp/gif ≤ 8 MB → URL
await auth.resetAvatar(); // DELETE → provider avatar URL
await auth.getBanner(); // banner has no upload/reset — provider-derived
await auth.deleteAccount(); // irreversible: profile, links, Animu Connect emails, sessionsgetAvatar resolves custom upload → cached provider → provider CDN, streaming
raw bytes. On React Native the binary methods need expo/fetch (global RN
fetch lacks arrayBuffer()), and uploadAvatar wants a Blob/Expo File
(RN {uri,type,name} FormData isn't accepted).
auth.browserLoginUrl(provider?); // …/login.php?start=<provider>Browser-capable providers also support server-driven mobile auth via
/mobile/<provider>-start.php (Discord GET, Apple POST form_post): open it
in a browser session, the backend handles the OAuth redirect and bounces the
session back through the app deep link — no native SDK.
import * as WebBrowser from "expo-web-browser";
const result = await WebBrowser.openAuthSessionAsync(
auth.mobileStartUrl("google"), // …/mobile/google-start.php
"animuapp://redirect", // provider's server redirect env var
);
if (result.type === "success") {
const parsed = auth.completeMobileAuth(result.url);
// { ok: true, token, action, userId } — token stored; use auth as usual
}Linking from inside the app: pass the current token as the second argument —
await WebBrowser.openAuthSessionAsync(auth.mobileStartUrl("apple", auth.sessionToken!), "animuapp://redirect");The backend issues CSRF state + PKCE and holds the verifier server-side; state
resolves from a server-side DB table (not the session cookie), so Apple's
cross-site form_post and Android custom tabs still resolve. Callbacks:
animuapp://redirect?token=<PHPSESSID>&action=<login|registered|linked>&user_id=<id>
animuapp://redirect?error=link_conflict|state|oauth[&msg=…]
Non-enveloped endpoints taking ?PHPSESSID=, kept for the unmodified mobile
app and pedidos scripts. Prefer v5.
| Method | Endpoint | Notes |
|---|---|---|
legacyExchangeToken(parameters) |
POST /mobile/exchange-token.php |
{ user, sessionToken, action }; user is discord_data for Discord-linked accounts, null otherwise. Accepts native Google serverAuthCode shapes. |
legacySessionStatus(sessionId) |
GET /mobile/session-status.php?PHPSESSID=… |
true for logged-in Discord sessions. |
legacySessionLogout(sessionId) |
GET /mobile/session-logout.php?PHPSESSID=… |
Destroys the session. |
Refusing error bodies ({ error, message? }) with HTTP 200 still throw
AnimuApiError carrying error as .code.
| Feature | Value |
|---|---|
| User-Agent | animu-api, or derived from clientInfo |
| Client headers | X-Client-* from clientInfo (native only) |
| Timeout | 20 s per request, AbortController-based |
| GET micro-cache | 2.5 s per URL; bypass with noCache or forceRefresh |
| JSON parsing | raw-text fallback for non-JSON bodies |
| Error type | AnimuApiError (statusCode: 0 for network/timeout) |
{ "rawtitle": "TK from Ling tosite sigure - P.S. Red I | Spider-Man: Into the Spider-verse", "listeners": 27, "track": { "artist": "TK from Ling tosite sigure", "title": "P.S. Red I", // may still carry the "| Anime" suffix "album": "P.S. Red I", "artworks": { "tiny": "…", "medium": "…", "large": "…" }, "timestart": 1789662023000, // epoch ms "duration": 256653, // ms (string in legacy payloads, coerced) "playlist": { "track_id": 18213, "title": "Animu Toca" } } }