c6ecbbeb25
No Spotify session (or paused, with failover_on_pause) for failover_delay_s switches to HLS; Spotify playing switches back within ~1 s. Switches fade out/in over 30 ms and flush the ring. HLS is suspended while Spotify plays. cspot is held back instead of drained while it isn't Spotify's turn: it keeps decoding on pause, so dropping its frames let it race through the queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
9.8 KiB
C
319 lines
9.8 KiB
C
#include "hls.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "aes67_cfg.h"
|
|
#include "esp_crt_bundle.h"
|
|
#include "esp_heap_caps.h"
|
|
#include "esp_http_client.h"
|
|
#include "esp_log.h"
|
|
#include "esp_timer.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
|
|
#define URL_MAX 384
|
|
#define PLAYLIST_MAX (64 * 1024)
|
|
#define MAX_SEGMENTS 64
|
|
#define LIVE_BACK 3 // start this many segments behind the live edge
|
|
#define CHUNK 4096
|
|
#define MAX_REDIRECTS 5
|
|
#define SEG_MAX (4 * 1024 * 1024) // largest segment we accept (PSRAM)
|
|
|
|
static const char *TAG = "hls";
|
|
|
|
typedef struct {
|
|
int target_s;
|
|
long long media_seq;
|
|
int count;
|
|
char uri[MAX_SEGMENTS][URL_MAX];
|
|
} media_pl_t;
|
|
|
|
static hls_sink_t s_sink;
|
|
static char *s_text; // playlist buffer (PSRAM)
|
|
static media_pl_t *s_pl; // parsed media playlist (PSRAM)
|
|
static char s_url[URL_MAX]; // configured URL
|
|
static char s_media_url[URL_MAX];
|
|
static uint8_t *s_seg; // current segment (PSRAM)
|
|
static volatile unsigned s_suspended; // HLS_SUSPEND_* bits
|
|
|
|
void hls_suspend(unsigned reason, bool suspend)
|
|
{
|
|
if (suspend) {
|
|
__atomic_or_fetch(&s_suspended, reason, __ATOMIC_SEQ_CST);
|
|
} else {
|
|
__atomic_and_fetch(&s_suspended, ~reason, __ATOMIC_SEQ_CST);
|
|
}
|
|
}
|
|
|
|
// hls_url when source.mode needs HLS, else "".
|
|
static void wanted_url(char *out, size_t n)
|
|
{
|
|
cJSON *src = cfg_get("source");
|
|
const char *mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
|
|
const char *url = cJSON_GetObjectItemCaseSensitive(src, "hls_url")->valuestring;
|
|
bool on = !strcmp(mode, "hls") || !strcmp(mode, "auto");
|
|
strlcpy(out, on ? url : "", n);
|
|
cJSON_Delete(src);
|
|
}
|
|
|
|
static bool still_wanted(void)
|
|
{
|
|
if (s_suspended) {
|
|
return false;
|
|
}
|
|
char u[URL_MAX];
|
|
wanted_url(u, sizeof(u));
|
|
return strcmp(u, s_url) == 0;
|
|
}
|
|
|
|
/* ----- HTTP ----- */
|
|
|
|
// Open a GET and follow redirects. Returns the client with headers read, or NULL.
|
|
static esp_http_client_handle_t http_open(const char *url)
|
|
{
|
|
esp_http_client_config_t cfg = {
|
|
.url = url, .crt_bundle_attach = esp_crt_bundle_attach, .timeout_ms = 10000,
|
|
.buffer_size = CHUNK, .buffer_size_tx = 1024, .user_agent = "p4-aes67",
|
|
.disable_auto_redirect = true, .keep_alive_enable = true,
|
|
};
|
|
esp_http_client_handle_t c = esp_http_client_init(&cfg);
|
|
if (!c) {
|
|
return NULL;
|
|
}
|
|
for (int i = 0; i <= MAX_REDIRECTS; i++) {
|
|
esp_err_t err = esp_http_client_open(c, 0);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "open %s: %s", url, esp_err_to_name(err));
|
|
break;
|
|
}
|
|
esp_http_client_fetch_headers(c);
|
|
int st = esp_http_client_get_status_code(c);
|
|
if (st == 301 || st == 302 || st == 303 || st == 307 || st == 308) {
|
|
esp_http_client_flush_response(c, NULL);
|
|
esp_http_client_set_redirection(c);
|
|
esp_http_client_close(c);
|
|
continue;
|
|
}
|
|
if (st == 200) {
|
|
return c;
|
|
}
|
|
ESP_LOGW(TAG, "GET %s: HTTP %d", url, st);
|
|
break;
|
|
}
|
|
esp_http_client_cleanup(c);
|
|
return NULL;
|
|
}
|
|
|
|
// GET into s_text (NUL-terminated). Returns length or -1.
|
|
static int http_get_text(const char *url)
|
|
{
|
|
esp_http_client_handle_t c = http_open(url);
|
|
if (!c) {
|
|
return -1;
|
|
}
|
|
int n = 0, r;
|
|
while (n < PLAYLIST_MAX - 1 && (r = esp_http_client_read(c, s_text + n, PLAYLIST_MAX - 1 - n)) > 0) {
|
|
n += r;
|
|
}
|
|
s_text[n] = 0;
|
|
esp_http_client_close(c);
|
|
esp_http_client_cleanup(c);
|
|
return n;
|
|
}
|
|
|
|
/* ----- playlists ----- */
|
|
|
|
// Absolute, host-relative ("/x") or path-relative ("x") reference against base.
|
|
static void resolve(char *out, size_t n, const char *base, const char *ref)
|
|
{
|
|
if (strstr(ref, "://")) {
|
|
strlcpy(out, ref, n);
|
|
return;
|
|
}
|
|
char b[URL_MAX];
|
|
strlcpy(b, base, sizeof(b));
|
|
char *q = strchr(b, '?');
|
|
if (q) {
|
|
*q = 0;
|
|
}
|
|
if (ref[0] == '/') {
|
|
char *host_end = strchr(strstr(b, "://") + 3, '/');
|
|
if (host_end) {
|
|
*host_end = 0;
|
|
}
|
|
} else {
|
|
char *slash = strrchr(b, '/');
|
|
if (slash) {
|
|
slash[1] = 0;
|
|
}
|
|
}
|
|
snprintf(out, n, "%s%s", b, ref);
|
|
}
|
|
|
|
// Next line of s_text (CRLF tolerant); NULL at the end.
|
|
static char *next_line(char **p)
|
|
{
|
|
if (!*p || !**p) {
|
|
return NULL;
|
|
}
|
|
char *line = *p, *e = strchr(line, '\n');
|
|
*p = e ? e + 1 : NULL;
|
|
if (e) {
|
|
*e = 0;
|
|
}
|
|
size_t l = strlen(line);
|
|
while (l && (line[l - 1] == '\r' || line[l - 1] == ' ')) {
|
|
line[--l] = 0;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
// Master playlist: pick the variant with the highest BANDWIDTH. false if s_text is a media playlist.
|
|
static bool pick_variant(const char *base, char *out, size_t n)
|
|
{
|
|
if (!strstr(s_text, "#EXT-X-STREAM-INF")) {
|
|
return false;
|
|
}
|
|
long best = -1, bw = -1;
|
|
char *p = s_text, *line;
|
|
while ((line = next_line(&p))) {
|
|
if (!strncmp(line, "#EXT-X-STREAM-INF:", 18)) {
|
|
const char *b = strstr(line, "BANDWIDTH=");
|
|
bw = b ? atol(b + 10) : 0;
|
|
} else if (line[0] && line[0] != '#' && bw >= 0) {
|
|
if (bw > best) {
|
|
best = bw;
|
|
resolve(out, n, base, line);
|
|
}
|
|
bw = -1;
|
|
}
|
|
}
|
|
ESP_LOGI(TAG, "variant %ld bit/s: %s", best, out);
|
|
return best >= 0;
|
|
}
|
|
|
|
static bool parse_media(const char *base)
|
|
{
|
|
s_pl->target_s = 10;
|
|
s_pl->media_seq = 0;
|
|
s_pl->count = 0;
|
|
char *p = s_text, *line;
|
|
bool is_m3u = false;
|
|
while ((line = next_line(&p))) {
|
|
if (!strcmp(line, "#EXTM3U")) {
|
|
is_m3u = true;
|
|
} else if (!strncmp(line, "#EXT-X-TARGETDURATION:", 22)) {
|
|
s_pl->target_s = atoi(line + 22);
|
|
} else if (!strncmp(line, "#EXT-X-MEDIA-SEQUENCE:", 22)) {
|
|
s_pl->media_seq = atoll(line + 22);
|
|
} else if (line[0] && line[0] != '#' && s_pl->count < MAX_SEGMENTS) {
|
|
resolve(s_pl->uri[s_pl->count++], URL_MAX, base, line);
|
|
}
|
|
}
|
|
return is_m3u && s_pl->count > 0;
|
|
}
|
|
|
|
/* ----- segments ----- */
|
|
|
|
static bool fetch_segment(long long seq, const char *url)
|
|
{
|
|
int64_t t0 = esp_timer_get_time();
|
|
esp_http_client_handle_t c = http_open(url);
|
|
if (!c) {
|
|
return false;
|
|
}
|
|
// Download completely first, then decode: the connection is not held open while the
|
|
// decoder waits for space in the ring (a stalled TCP stream for ~10 s may get cut by the CDN).
|
|
size_t total = 0;
|
|
int r = 0;
|
|
bool ok = true;
|
|
while (total < SEG_MAX && (r = esp_http_client_read(c, (char *)s_seg + total, SEG_MAX - total)) > 0) {
|
|
total += r;
|
|
}
|
|
if (r < 0 || total == SEG_MAX) {
|
|
ok = false;
|
|
}
|
|
esp_http_client_close(c);
|
|
esp_http_client_cleanup(c);
|
|
int64_t us = esp_timer_get_time() - t0;
|
|
ESP_LOGD(TAG, "segment %lld: %u bytes in %.2f s (%.1f Mbit/s)%s", seq, (unsigned)total, us / 1e6,
|
|
us ? total * 8.0 / us : 0.0, ok ? "" : ", failed");
|
|
if (ok && s_sink) {
|
|
ok = s_sink(s_seg, total, true); // blocks at playback speed while the ring is full
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
static void hls_task(void *arg)
|
|
{
|
|
while (1) {
|
|
wanted_url(s_url, sizeof(s_url));
|
|
if (!s_url[0] || s_suspended) {
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
continue;
|
|
}
|
|
ESP_LOGI(TAG, "start: %s", s_url);
|
|
// Master -> media playlist
|
|
if (http_get_text(s_url) < 0) {
|
|
vTaskDelay(pdMS_TO_TICKS(5000));
|
|
continue;
|
|
}
|
|
if (!pick_variant(s_url, s_media_url, sizeof(s_media_url))) {
|
|
strlcpy(s_media_url, s_url, sizeof(s_media_url));
|
|
}
|
|
long long next = -1;
|
|
int errors = 0;
|
|
while (still_wanted() && errors < 5) {
|
|
int64_t t0 = esp_timer_get_time();
|
|
if (http_get_text(s_media_url) < 0 || !parse_media(s_media_url)) {
|
|
errors++;
|
|
vTaskDelay(pdMS_TO_TICKS(2000));
|
|
continue;
|
|
}
|
|
long long first = s_pl->media_seq, last = first + s_pl->count - 1;
|
|
if (next < 0) {
|
|
next = last - LIVE_BACK + 1 > first ? last - LIVE_BACK + 1 : first;
|
|
ESP_LOGI(TAG, "live playlist: seq %lld..%lld, target %d s, starting at %lld",
|
|
first, last, s_pl->target_s, next);
|
|
} else if (next < first) {
|
|
ESP_LOGW(TAG, "fell behind the live window (%lld < %lld), skipping ahead", next, first);
|
|
next = first;
|
|
}
|
|
bool got_new = false;
|
|
for (; next <= last && still_wanted(); next++) {
|
|
if (!fetch_segment(next, s_pl->uri[next - first])) {
|
|
errors++;
|
|
break;
|
|
}
|
|
errors = 0;
|
|
got_new = true;
|
|
}
|
|
// No new segment: wait half the target duration before reloading (RFC 8216 6.3.4).
|
|
if (!got_new) {
|
|
int64_t wait = s_pl->target_s * 500000LL - (esp_timer_get_time() - t0);
|
|
if (wait > 0) {
|
|
vTaskDelay(pdMS_TO_TICKS(wait / 1000));
|
|
}
|
|
}
|
|
}
|
|
if (errors >= 5) {
|
|
ESP_LOGW(TAG, "too many errors, restarting from the playlist in 5 s");
|
|
vTaskDelay(pdMS_TO_TICKS(5000));
|
|
}
|
|
}
|
|
}
|
|
|
|
esp_err_t hls_start(hls_sink_t sink)
|
|
{
|
|
s_sink = sink;
|
|
s_text = heap_caps_malloc(PLAYLIST_MAX, MALLOC_CAP_SPIRAM);
|
|
s_pl = heap_caps_malloc(sizeof(media_pl_t), MALLOC_CAP_SPIRAM);
|
|
s_seg = heap_caps_malloc(SEG_MAX, MALLOC_CAP_SPIRAM);
|
|
if (!s_text || !s_pl || !s_seg) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
return xTaskCreate(hls_task, "hls", 10240, NULL, 4, NULL) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
|
|
}
|