diff --git a/main/audio/podcast_manifest.c b/main/audio/podcast_manifest.c new file mode 100644 index 0000000..e4824e8 --- /dev/null +++ b/main/audio/podcast_manifest.c @@ -0,0 +1,91 @@ +// See podcast_manifest.h. The pure parser is host-testable (no ESP-IDF deps); +// the SD loader is compiled only on-target (#ifndef LISAEL_HOST_TEST). +#include "audio/podcast_manifest.h" +#include "cJSON.h" +#include +#include +#include + +int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows) +{ + if (!json || !shows || max_shows <= 0) { + return -1; + } + cJSON *root = cJSON_Parse(json); + if (!cJSON_IsArray(root)) { + cJSON_Delete(root); + return -1; + } + + int n_shows = 0; + cJSON *jshow; + cJSON_ArrayForEach(jshow, root) { + if (n_shows >= max_shows) { + break; + } + cJSON *jname = cJSON_GetObjectItem(jshow, "show"); + cJSON *jcover = cJSON_GetObjectItem(jshow, "cover"); + cJSON *jeps = cJSON_GetObjectItem(jshow, "episodes"); + if (!cJSON_IsString(jname) || !cJSON_IsArray(jeps)) { + continue; + } + lisael_show_t *s = &shows[n_shows]; + memset(s, 0, sizeof(*s)); + snprintf(s->show, sizeof(s->show), "%s", jname->valuestring); + if (cJSON_IsString(jcover)) { + snprintf(s->cover, sizeof(s->cover), "%s", jcover->valuestring); + } + cJSON *jep; + cJSON_ArrayForEach(jep, jeps) { + if (s->n_eps >= LISAEL_MAX_EPISODES) { + break; + } + cJSON *jf = cJSON_GetObjectItem(jep, "file"); + cJSON *jt = cJSON_GetObjectItem(jep, "title"); + if (!cJSON_IsString(jf)) { + continue; + } + lisael_episode_t *e = &s->eps[s->n_eps]; + snprintf(e->file, sizeof(e->file), "%s", jf->valuestring); + snprintf(e->title, sizeof(e->title), "%s", + cJSON_IsString(jt) ? jt->valuestring : jf->valuestring); + s->n_eps++; + } + if (s->n_eps > 0) { + n_shows++; // skip shows with no playable episode + } + } + cJSON_Delete(root); + return n_shows; +} + +#ifndef LISAEL_HOST_TEST +#include "esp_log.h" +int lisael_podcast_load(lisael_show_t *shows, int max_shows) +{ + const char *path = "/sdcard/podcasts/manifest.json"; + FILE *f = fopen(path, "r"); + if (!f) { + return -1; + } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz <= 0 || sz > 64 * 1024) { + fclose(f); + return -1; + } + char *buf = malloc((size_t)sz + 1); + if (!buf) { + fclose(f); + return -1; + } + size_t rd = fread(buf, 1, (size_t)sz, f); + buf[rd] = '\0'; + fclose(f); + int n = lisael_podcast_parse(buf, shows, max_shows); + free(buf); + ESP_LOGI("podcast", "loaded %d show(s) from %s", n, path); + return n; +} +#endif diff --git a/main/audio/podcast_manifest.h b/main/audio/podcast_manifest.h new file mode 100644 index 0000000..b3bcfec --- /dev/null +++ b/main/audio/podcast_manifest.h @@ -0,0 +1,36 @@ +// Podcast manifest v2 (grouped by show) for the Lisael Box "Histoires" screen. +// /sdcard/podcasts/manifest.json = JSON array of shows, each with a cover and +// a list of episodes. ASCII file names; UTF-8 show/title strings. +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +// Caps kept small: lisael_show_t is ~1.8 KB and the Histoires screen keeps a +// static array of these. Use static/heap storage, never a task stack. +#define LISAEL_MAX_SHOWS 8 +#define LISAEL_MAX_EPISODES 12 + +typedef struct { + char file[64]; // audio basename under /sdcard/podcasts/ (e.g. "oli_01.m4a") + char title[80]; // UTF-8 display title +} lisael_episode_t; + +typedef struct { + char show[48]; // UTF-8 show name + char cover[40]; // cover basename ("oli.bin"), "" if none + lisael_episode_t eps[LISAEL_MAX_EPISODES]; + int n_eps; +} lisael_show_t; + +// Pure parser: JSON string -> shows[]. Skips shows with zero episodes. +// Returns show count (>=0), or -1 if the JSON is not a valid array. +int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows); + +// Read /sdcard/podcasts/manifest.json and parse it. Returns count or -1. +int lisael_podcast_load(lisael_show_t *shows, int max_shows); + +#ifdef __cplusplus +} +#endif diff --git a/test/host/test_podcast_manifest.c b/test/host/test_podcast_manifest.c new file mode 100644 index 0000000..3e4ce26 --- /dev/null +++ b/test/host/test_podcast_manifest.c @@ -0,0 +1,41 @@ +// Host unit test for the v2 podcast manifest parser. +#define LISAEL_HOST_TEST +#include "audio/podcast_manifest.h" +#include +#include +#include + +static const char *SAMPLE = +"[{\"show\":\"Une histoire et Oli\",\"cover\":\"oli.bin\",\"episodes\":[" + "{\"file\":\"oli_01.m4a\",\"title\":\"Concert p.1\"}," + "{\"file\":\"oli_02.m4a\",\"title\":\"Concert p.2\"}]}," + "{\"show\":\"Bestioles\",\"cover\":\"bestioles.bin\",\"episodes\":[" + "{\"file\":\"bestioles_01.mp3\",\"title\":\"La gerboise\"}]}," + "{\"show\":\"Vide\",\"episodes\":[]}]"; + +int main(void) { + lisael_show_t shows[LISAEL_MAX_SHOWS]; + int n = lisael_podcast_parse(SAMPLE, shows, LISAEL_MAX_SHOWS); + assert(n == 2); // empty show skipped + assert(strcmp(shows[0].show, "Une histoire et Oli") == 0); + assert(strcmp(shows[0].cover, "oli.bin") == 0); + assert(shows[0].n_eps == 2); + assert(strcmp(shows[0].eps[1].file, "oli_02.m4a") == 0); + assert(strcmp(shows[0].eps[0].title, "Concert p.1") == 0); + assert(strcmp(shows[1].show, "Bestioles") == 0); + assert(shows[1].n_eps == 1); + assert(lisael_podcast_parse("not json", shows, LISAEL_MAX_SHOWS) == -1); + + // title falls back to the file name when "title" is absent + lisael_show_t s2[LISAEL_MAX_SHOWS]; + int m = lisael_podcast_parse( + "[{\"show\":\"S\",\"episodes\":[{\"file\":\"a.mp3\"}]}]", s2, LISAEL_MAX_SHOWS); + assert(m == 1 && s2[0].n_eps == 1); + assert(strcmp(s2[0].eps[0].title, "a.mp3") == 0); + + // guard clauses + assert(lisael_podcast_parse(NULL, s2, LISAEL_MAX_SHOWS) == -1); + assert(lisael_podcast_parse("[]", s2, 0) == -1); + printf("OK: %d shows\n", n); + return 0; +}