Files
aes67-ESP32-P4/main/hls.c
T
bsncubed c6ecbbeb25 Player: auto mode fails over between Spotify and HLS
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>
2026-09-25 22:36:11 +10:00

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;
}