lets.dj

Answering on demand

Everything in Sending a catalogue assumes a catalogue that exists. Some sources do not have one: what they have is the ability to find something and then go and get it. A provider like that cannot mirror its catalogue to us, because its catalogue is the internet.

So it answers questions instead. Three of them:

  • Search. “Do you have anything for these words?”
  • Preview. “Somebody wants to hear a moment of that before committing.”
  • Fetch. “Somebody queued that one. Go and make it real.”

We still never dial you. That is the whole premise of pushing, and nothing here changes it: all three are jobs you come and collect. If your machine is asleep, the party simply does not get answers, which is the same failure mode as a bridge that is switched off.

Only providers reporting canSearchRemotely are ever asked, and preview is a separate opt-in on top of it. A bridge that does not implement any of this is never given a job and needs no changes.

Collecting work

GET https://api.lets.dj/providers/jobs?max=4
Authorization: Bearer <sync token>

{
  "jobs": [
    { "id": "j97…", "kind": "search", "term": "diem xua" },
    { "id": "j57…", "kind": "fetch", "externalId": "abc123", "position": 3 },
    { "id": "j21…", "kind": "preview", "externalId": "def456" }
  ],
  "cancelled": ["j91…"]
}

Poll it about once a second. Returning a job claims it for you. max is how many you will take, from 1 to 20, and four when you leave it out; a larger number is cut to 20. A job.id is opaque: use it only to report back.

Jobs come in a fixed order, and you should keep it. Fetches first, lowest position first. Then searches, oldest first. Then previews, oldest first.

position is where that song sits in the queue it was raised for. Take the lowest first. Three songs queued in one burst otherwise arrive in the order the taps landed, and the room waits on the wrong one.

A preview carries no position, and should be taken last. Somebody’s next song outranks somebody else’s browsing, always.

cancelled lists jobs you hold that nobody is waiting for any more: the song was removed, or the person who queued it left. Stop work on them. They are reported once, so act on them when you see them.

A claimed job that goes quiet for a minute is handed back out, on the assumption you restarted. Every report you send counts as not being quiet. Finish or fail your work rather than dropping it.

Reporting back

POST https://api.lets.dj/providers/jobs/<id>
Authorization: Bearer <sync token>
Content-Type: application/json

While working, as often as is useful and no more than every couple of seconds:

{ "state": "working", "phase": "downloading", "percent": 42 }

phase exists because percentages lie: fetching two streams and then muxing them produces a bar that rewinds unless you can say what changed. percent is optional. Omit it rather than inventing one. It is a number from 0 to 100, and what is shown is clamped to that.

The phases are an ordered set, and you may never go backwards through it:

starting → downloading → converting → verifying → finalizing

Skip any you do not do. Send nothing at all if you would rather not say. But a room that has been told a song is finalizing and then sees downloading learns that the words mean nothing, which is worse than never having had them, because it also cannot trust the bar beside them.

Nothing on our side checks this. phase is stored as the text you sent, and the ordering is yours to keep. What a person sees is not your word but ours: while somebody waits on a preview, starting becomes “Finding…”, downloading “Loading…”, retrying “Retrying…”, and converting, verifying and finalizing all become “Almost…”. A word outside the vocabulary is shown as the general “Loading…” rather than as itself.

The trap is sidecar work. Fetching artwork or metadata often happens before the media and may report its own progress; if that gets labelled with a late-sounding phase, or its percentage is mistaken for the media’s, the sequence runs backwards in exactly the way this rule forbids. Either keep silent through it or call it starting.

One word sits outside the sequence: retrying, for an attempt that failed and is being made again. It may appear at any point, and downloading may follow it from zero. That is the one case where a bar honestly restarts, and naming it beats a percentage that appears to fall on its own.

Finishing a search means handing back candidates, at most 50 (any more are dropped):

{
  "state": "done",
  "results": [
    {
      "externalId": "abc123",
      "title": "Diễm Xưa",
      "artist": "Trịnh Công Sơn",
      "durationSeconds": 254,
      "thumbnailUrl": "https://…"
    }
  ]
}

These are not catalogue entries and nothing is stored as a track until somebody chooses one. A candidate with no externalId or no title cannot be chosen or fetched, and is dropped. A candidate that is already in the library as an ordinary track is hidden from the person who asked, because the point of asking twice is to find what the library does not already have. externalId must be the same id you would report for that track in a sync, or the fetch that follows will ask you for something you cannot match.

Finishing a fetch is two steps, in this order: sync the new track first, then report the job done. The sync is what makes the song real; reporting first leaves a queue entry pointing at a track that does not exist yet. Send that sync with final: false. A sync that leaves final out is a replacement, and a replacement of one track removes the rest of your library.

{ "state": "done" }

Finishing a preview is the same single line, and deliberately not followed by a sync: a preview is not a catalogue entry and must never appear as one. Reporting it done means only that the bytes described in Previewing can now be served.

And when it will not work:

{ "state": "failed", "error": "video is unavailable in this region" }

Say what actually happened. The person who queued it is told why their song vanished, and “something went wrong” tells them nothing they had not already worked out. A failed fetch takes the song out of the queue everywhere it was waiting.

The response to any of these is { "ok": true, "cancelled": false }. A cancelled: true means stop: the job was abandoned while you were working on it. A job that is not yours, or not there, is a 404. A state other than working, done or failed is a 400.

Waiting, from the party’s side

A queued song whose file does not exist yet is held as pending. It is in the queue, it shows its progress, and it is never handed to the screen as a URL, so a half-downloaded song is never a black rectangle in front of a room. The sync that eventually reports its externalId is what makes it ordinary.

By default a party plays the first song that is ready rather than the first song in the queue, so a download never stalls a room; the host can turn that off and wait in strict order instead. Neither is your concern beyond answering promptly, but it is why a slow fetch is survivable.

Previewing

Six karaoke cuts of the same song are indistinguishable by title, and a wrong guess costs a room three minutes. A preview lets somebody hear a moment of one before committing the party to it.

Opt in with canPreview alongside canSearchRemotely, then serve preview bytes at the media route you already have:

GET <baseUrl>/media/<externalId>?t=<mediaToken>&preview=1

Same token, same range and CORS requirements, same 403 on a wrong token. One more branch in the handler you have already written, rather than a second server.

A preview may be a cheaper rendition than the real thing. Lower resolution, a shorter excerpt, whatever is quick: nobody is singing to it. We never serve it to the screen. It reaches one person’s phone, on request, and the party’s own playback always waits for a real fetch.

What you do with it afterwards is your business. If somebody queues a song you have already previewed and your preview happens to be good enough to keep, a later fetch for it can finish almost instantly. That is an optimisation inside your cache, not a rule: a provider that throws previews away and re-fetches properly is equally correct, and nothing here needs to know which you chose.

Previews are speculative, because they are media nobody has committed to singing, so they deserve their own ceiling on your disk and their own eviction, kept apart from the library a party is actually drawing on.

Saying what you will put up with

A fetch spends somebody’s disk, bandwidth and CPU, and that somebody is you. Send limits alongside capabilities in any sync, and we enforce them:

"limits": {
  "pendingFetchesPerRoom": 5,
  "pendingFetchesPerPerson": 2,
  "pendingPreviewsPerRoom": 3,
  "guestRequests": true,
  "fetchTimeoutSeconds": 600,
  "previewTimeoutSeconds": 120
}

All optional. guestRequests: false limits asking to hosts, which is the answer when a laptop is lent to a room of forty. fetchTimeoutSeconds is when we give up on you and tell the singer. Refusals name the limit, so the person hitting it is told what it is rather than that they cannot.

Previews get their own budget because they are cheap to ask for and easy to ask for repeatedly: a room of curious people tapping through search results should not be able to spend an evening of somebody’s bandwidth. Give up on one sooner than a fetch, too. Nobody waits two minutes to hear whether a song is the right cut.

Anything not sent gets a default. There is no ceiling on pending fetches or previews, guests may ask, a fetch is given up on after 600 seconds, and a preview after 120. A count or a number of seconds must be a positive number, and is rounded down; anything else is taken as not sent. guestRequests is only ever turned off by a literal false.

Limits are yours to set because the machine is yours; what a party does about a slow song is the host’s, and that is not negotiated here.

A console of your own

If you have an interface, for pre-loading before a party or for whatever else, report it as consoleUrl in the sync payload, on the same terms as baseUrl: HTTPS, and a certificate a browser trusts. The library’s owner, and nobody else, is offered a link to it.

Secure it yourself, and not with anything we hold. We show an address; we do not want your secret, and a link that carries one is a link that leaks.