Tape is a generic audio player. It ships with no music and no preconfigured server. On first launch the user enters the URL of an audio-library server they control; Tape talks to that server over the HTTP API described here. Any server that implements this API works with Tape — exactly like a Lampa/Luxo-style player connecting to a self-hosted source.
This document is the contract. If your server answers these endpoints with the shapes below, Tape can browse and play your library.
Two stages, in order:
https://music.example.com). Tape probes it and, if valid, stores the
URL locally. The app is now connected but unauthenticated — it can browse
whatever the server exposes publicly.Tape bundles no content, no server list, no analytics. Everything comes from the connected server.
/api/v1 prefix.B, a path /foo is requested at B/api/v1/foo.snake_case.When the user submits a URL, Tape issues:
GET {B}/api/v1/public/stats
Response 200:
{
"tracks_count": 1240,
"sample_covers": [12, 87, 301],
"sample_albums": [
{ "id": 12, "title": "Chill Beats", "artist": "Lo-Fi Demo", "year": 2024 }
]
}
sample_albums / sample_covers fill the “New” row on the Home screen.
Either may be empty; sample_albums is preferred.
Tokens are a JWT access/refresh pair. Send the access token as
Authorization: Bearer <access_token> on authenticated requests.
POST {B}/api/v1/auth/login body: { "email": "...", "password": "..." }
POST {B}/api/v1/auth/register body: { "email": "...", "password": "..." } (≥8 chars)
→ { "access_token": "...", "refresh_token": "..." }
POST {B}/api/v1/auth/tg/login body: { "code": "123456" }
→ same token pair. (How the user obtains the code is the server’s business, e.g. a Telegram bot.)
POST {B}/api/v1/auth/refresh body: { "refresh_token": "..." } → new token pair
POST {B}/api/v1/auth/logout body: { "refresh_token": "..." }
GET {B}/api/v1/auth/me → profile
/auth/me →
{
"id": 7, "email": "user@example.com",
"tg_username": null, "tg_first_name": null,
"role": "user",
"has_access": true,
"avatar_url": null, "emoji_url": null,
"logs_requested": false,
"liked_track_ids": [88, 91]
}
Required: id, has_access. Everything else may be omitted or null.
has_access lets the server signal whether this account may stream (e.g. trial
active). Tape treats false as “no playback for this user”.
liked_track_ids primes the heart icons without a second request; omit it and
Tape falls back to GET /library/liked.
GET {B}/api/v1/library/tracks/random?limit=10&exclude_ids=4,9,21
→ a list of tracks. Tape uses this both to start “My Vibe” and to top up the queue. Each track:
{
"id": 88, "title": "Rainy Window", "artist": "Lo-Fi Demo",
"artists_display": "Lo-Fi Demo", "album": "Chill Beats",
"duration_seconds": 184, "cover_album_id": 12,
"bpm": 72.0, "loudness_lufs": -14.2,
"intro_end_seconds": null, "outro_start_seconds": null, "first_beat_seconds": null
}
Required: id, title, artist. A track without artist fails to decode
and the whole response is dropped — an empty queue, not a nameless track.
bpm / loudness_lufs are optional but power Tape’s energy-coherent “Vibe”
ordering. Every other field is best-effort.
GET {B}/api/v1/library/tracks/{id}/stream?token={access_token}
audio/mpeg, etc.).Range requests (AVPlayer seeks via Range).?token= query param or the
Authorization header (the query form is required because AVPlayer can’t set
headers).GET {B}/api/v1/library/cover/{album_id}
GET {B}/api/v1/library/artist-cover/{artist_id}
GET {B}/api/v1/library/track-cover/{track_id}
A 404 simply means “no art” — Tape falls back to a generated gradient.
GET {B}/api/v1/library/artists → [ { id, name, cover_rev? } ]
GET {B}/api/v1/library/artists/{id} → artist + discography
GET {B}/api/v1/library/artists/{id}/top-tracks → [ top-track ]
GET {B}/api/v1/library/albums/{id} → album + tracks
GET {B}/api/v1/library/search?q=text → search results
/library/artists/{id} → required id, name, albums, is_subscribed,
subscribers_count, monthly_listeners:
{
"id": 3, "name": "Lo-Fi Demo", "bio": null, "cover_rev": 1,
"is_subscribed": false, "subscribers_count": 0, "monthly_listeners": 0,
"albums": [ { "id": 12, "title": "Chill Beats", "year": 2024,
"track_count": 8, "release_type": "album" } ]
}
release_type is one of album / ep / single and decides which shelf the
release lands on.
/library/albums/{id} → required id, title, artist_id, tracks:
{
"id": 12, "title": "Chill Beats", "year": 2024, "artist_id": 3,
"artists_display": "Lo-Fi Demo",
"tracks": [ { "id": 88, "title": "Rainy Window", "duration_seconds": 184,
"track_number": 1, "artists_display": "Lo-Fi Demo" } ]
}
Omitting artist_id does not degrade the screen — it makes the response
undecodable, and the album reads as “no tracks”.
/library/search → required artists, albums, tracks, layout_fixed:
{
"artists": [ { "id": 3, "name": "Lo-Fi Demo" } ],
"albums": [ { "id": 12, "title": "Chill Beats", "artist": "Lo-Fi Demo" } ],
"tracks": [],
"layout_fixed": true,
"query": "chill"
}
tracks uses the top-track shape below.
layout_fixed is a plain boolean and is not optional. Set it to true if
you searched exactly what was typed; false means the server retried the query
some other way (e.g. a keyboard-layout transposition) and query reports what
was actually searched.
Top-track shape — used by /artists/{id}/top-tracks, /library/search,
/library/me/top-tracks and /library/me/recent. It is not the same shape
as /tracks/random; required id, title, album_id, album_title,
play_count:
{
"id": 88, "title": "Rainy Window", "album_id": 12,
"album_title": "Chill Beats", "play_count": 4,
"duration_seconds": 184, "artists_display": "Lo-Fi Demo",
"cover_album_id": 12
}
GET {B}/api/v1/library/liked → [ liked-track ]
POST {B}/api/v1/library/liked/{track_id} → toggle like (204)
POST {B}/api/v1/library/plays body:{track_id} → record a play (204)
GET {B}/api/v1/library/me/top-tracks → [ top-track ]
GET {B}/api/v1/library/me/recent → [ top-track ]
GET {B}/api/v1/library/me/stats → { plays_total, tracks_listened, liked_count }
POST {B}/api/v1/library/tracks/{track_id}/dislike → "don't recommend"
Liked-track shape — required id, title, artist, album (note: this is
a third shape, flatter than the two above):
{ "id": 88, "title": "Rainy Window", "artist": "Lo-Fi Demo",
"album": "Chill Beats", "duration_seconds": 184 }
Playlists are seven separate endpoints, not “CRUD under the same path”:
GET {B}/api/v1/library/playlists → [ playlist ]
POST {B}/api/v1/library/playlists body:{name} → playlist (201)
GET {B}/api/v1/library/playlists/{id} → playlist + tracks
PATCH {B}/api/v1/library/playlists/{id} body:{name} → playlist
DELETE {B}/api/v1/library/playlists/{id} (204)
POST {B}/api/v1/library/playlists/{id}/tracks body:{track_id} (204)
DELETE {B}/api/v1/library/playlists/{id}/tracks/{track_id} (204)
A playlist is { id, name, track_count, cover_album_id?, updated_at }; the
detail endpoint adds tracks: [ top-track ].
POST /logs/report (optional)Tape has a Send logs row in its profile screen. It uploads the app’s in-memory log buffer to the connected server — never to the app’s developer — so that the server’s operator can look at a problem that does not reproduce on their side. The row exists only if the server answers this endpoint.
POST {B}/api/v1/logs/report Authorization optional → 201
Request body (what Tape sends — a server may store or ignore any of it):
{
"source": "manual",
"platform": "ios",
"description": "User-initiated diagnostic upload from iOS Settings",
"build_version": "42",
"user_agent": "tape-ios/3.2 (Version 26.4 (Build 23E123))",
"payload": {
"events": [
{ "ts": "2026-09-02T14:03:11Z", "level": "warn", "src": "http",
"summary": "GET /library/albums/12 -> 404",
"meta": { "status": "404" } }
],
"device": {
"model": "iPhone15,3", "os": "Version 26.4 (Build 23E123)",
"appVersion": "3.2", "build": "42"
}
}
}
source is always manual from the app: a report goes out only when the
user taps Send logs, or agrees to a request from the operator (below).
Tape never uploads diagnostics on its own.platform is ios.events inside payload holds the most recent entries of a rolling buffer
capped at 500 entries; meta is omitted when empty. model inside
device is the hardware identifier, not the device’s name — the report
carries no name, contact or location.413 above it.Response 201:
{ "id": 731 }
Tape shows the id to the user as a receipt (“Logs sent · #731”), so return
one even if you discard the body.
Tape probes the endpoint once per connection and shows the row unless the server says the route does not exist. Only two answers mean that:
Answer to GET {B}/api/v1/logs/report |
What Tape does |
|---|---|
404 or 501 |
the Send logs row is hidden |
anything else (405, 401, 403, 422, 429, 2xx) |
the row is shown — the route exists |
| network error / timeout | the row is shown (“unknown” is not “absent”) |
So a server that does not want diagnostics need only answer 404 on the path.
A server that implements only POST will typically answer 405 to the probe —
that is read as “route exists”, which is correct.
If /auth/me returns "logs_requested": true, Tape asks the signed-in user
whether to send a report now. Whatever they answer, it then calls
POST {B}/api/v1/auth/me/logs-request/resolve → 2xx
to clear the flag, and sends the report (with description =
Admin-requested diagnostics (user consented)) only if they agreed.
Omit logs_requested (or return false) and this flow never starts.
To connect and play music, a server needs only:
GET /api/v1/public/stats — connection probe + Home contentGET /api/v1/library/tracks/random — queue sourceGET /api/v1/library/tracks/{id}/stream — audio, with Range supportGET /api/v1/library/cover/{id} — artwork (or just return 404)POST /api/v1/auth/login + GET /api/v1/auth/meEverything else (search, artists, likes, playlists, history, Telegram auth, diagnostics) is optional and degrades gracefully when absent.
404, so a server built from this document alone is complete for
browsing and playback.