tape-site

Tape Server API

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.


1. How Tape works (overview)

Two stages, in order:

  1. Connect a server (required, first). The user types a base URL (e.g. 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.
  2. Identify (optional, server-decided). The server may offer sign-in (email/password, or a Telegram one-time code). Identity unlocks per-user features (likes, playlists, history). Tape never requires an account — the server decides whether playback needs one.

Tape bundles no content, no server list, no analytics. Everything comes from the connected server.


2. Base URL & versioning


3. Connection probe — makes a server “Tape-compatible”

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.


4. Authentication (optional)

Tokens are a JWT access/refresh pair. Send the access token as Authorization: Bearer <access_token> on authenticated requests.

Email / password

POST {B}/api/v1/auth/login        body: { "email": "...", "password": "..." }
POST {B}/api/v1/auth/register     body: { "email": "...", "password": "..." }   (≥8 chars)

{ "access_token": "...", "refresh_token": "..." }

Telegram one-time code (optional second method)

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.)

Session

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.


5. Playback — the minimum to make music play

Endless queue source

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.

Audio stream

GET {B}/api/v1/library/tracks/{id}/stream?token={access_token}

Artwork (binary image responses)

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
}

7. Per-user features (optional, need a token)

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 ].


8. Diagnostics — 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"
    }
  }
}

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.

Hiding the row on a server that has no such endpoint

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.

Operator-requested diagnostics (optional, needs identity)

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.


9. Minimum viable server (checklist)

To connect and play music, a server needs only:

Everything else (search, artists, likes, playlists, history, Telegram auth, diagnostics) is optional and degrades gracefully when absent.


10. Notes for server authors