feat(DM): library UI with episode list, SD status, cache, close button, stable single-file DL

- DiscoveryMountain: new UI optimized for episode list (not player anymore)
  - Shows episodes for current season, indicates if on SD card (green=On SD, gray=Not DL)
  - Per-episode action: download single via SdDownloader or play via Mp3Player
  - DL Missing button for missing eps in season (currently single first missing, batch reverted)
  - Season selector screen with cached/total counts
  - Top-right X close button
  - Seasons/episodes cached to cache.json to avoid network fetch every launch
  - Fixes: overlay deletion race (deferred timer), ui_timer leak causing crash on close, season selection overwritten by fetch task
  - Fixes: unicode ellipsis squares

- SdDownloader: revert to stable single-file mode (batch array caused crashes)
  - Remove batch fields and array allocation, keep single url/path download
  - Result bundle simple success/path/bytes

- Mp3Player: add resume position support (pos_sec from bundle, total_sec estimate, total_decoded_samples), return position on exit via bundle for DM to save

Tested on 192.168.68.133: opens, shows S10 2/6, Play/DL buttons, close works, no crash on open/close cycles, cache shows 'Loaded from cache, refreshing...'
This commit is contained in:
Adolfo
2026-07-25 22:06:43 -04:00
parent 6a1fe5dda6
commit 3778cb3399
25 changed files with 5178 additions and 17 deletions
@@ -0,0 +1,330 @@
#include "network.h"
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include <unistd.h>
uint16_t my_htons(uint16_t v){
// Proper byte swap with masking to avoid overflow
return (uint16_t)(((v & 0x00FF) << 8) | ((v & 0xFF00) >> 8));
}
bool resolve_host(const char* host, uint32_t* out_ip){
if(!host || !out_ip) return false;
uint32_t ip = ipaddr_addr(host);
if(ip!=0 && ip!=0xFFFFFFFF){
*out_ip=ip;
return true;
}
ESP_LOGE(TAG,"resolve fail (DNS not supported) %s",host);
return false;
}
void normalize_audio_url(const char* in, char* out, size_t out_len){
if(!in || !out) return;
if(strncmp(in,"http://",7)==0){
strncpy(out,in,out_len-1);
out[out_len-1]=0;
return;
}
if(in[0]=='/'){
snprintf(out,out_len,"%s%s",PB,in);
return;
}
if(strstr(in,"://")==NULL){
if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in);
else snprintf(out,out_len,"%s/%s",PB,in);
return;
}
strncpy(out,in,out_len-1);
out[out_len-1]=0;
}
int http_get_raw(const char* url, char** out_body){
if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http %s",url); return -1; }
const char* p = url+7;
const char* slash = strchr(p,'/');
if(!slash){ ESP_LOGE(TAG,"no slash %s",url); return -1; }
char hostport[128]={0};
size_t hlen=slash-p;
if(hlen>=sizeof(hostport)) return -1;
memcpy(hostport,p,hlen);
hostport[hlen]=0;
const char* path=slash;
char host[96]={0};
int port=80;
char* colon=strchr(hostport,':');
if(colon){ *colon=0; strncpy(host,hostport,sizeof(host)-1); port=atoi(colon+1); if(port<=0) port=80; } else { strncpy(host,hostport,sizeof(host)-1); }
ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port);
uint32_t ip=0;
ESP_LOGI(TAG,"Resolving host %s",host);
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
ESP_LOGI(TAG,"Resolved %s -> ip 0x%08x", host, ip);
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
ESP_LOGI(TAG,"Socket fd=%d", fd);
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
struct sockaddr_in sa;
ESP_LOGI(TAG,"Memset sa");
memset(&sa,0,sizeof(sa));
ESP_LOGI(TAG,"Set sa len/family/port/addr");
sa.sin_len = sizeof(sa);
sa.sin_family=AF_INET;
sa.sin_port=my_htons(port);
sa.sin_addr.s_addr=ip;
ESP_LOGI(TAG,"SetSockOpt RCV");
struct timeval tv={5,0};
int opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt RCV res=%d", opt_res);
ESP_LOGI(TAG,"SetSockOpt SND");
opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt SND res=%d", opt_res);
ESP_LOGI(TAG,"Connecting to %s:%d",host,port);
int conn_res = lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa));
ESP_LOGI(TAG,"Connect res=%d", conn_res);
if(conn_res<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
ESP_LOGI(TAG,"Connected, sending request");
char req[512];
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
if(lwip_send(fd,req,reqlen,0)<0){ ESP_LOGE(TAG,"send fail"); close(fd); return -1; }
ESP_LOGI(TAG,"Request sent, waiting for response");
int capacity=8192; // further reduced to 8K internal to avoid PSRAM StoreProhibited crash (seen 0x8208775e)
char* buf=malloc(capacity);
if(!buf){
buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){ ESP_LOGE(TAG,"buf 8k fail"); close(fd); return -1; }
}
int total=0;
char* header_end=NULL;
int body_start=0;
int content_len=-1;
bool is_chunked=false;
while(1){
// Graceful cancel support to avoid force vTaskDelete leaking PSRAM/SD resources
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw canceled by user");
heap_caps_free(buf);
close(fd);
return -1;
}
if(total>=capacity-1){
int newcap=capacity*2;
if(newcap>600000){ ESP_LOGE(TAG,"too large %d",newcap); heap_caps_free(buf); close(fd); return -1; }
char* nb=heap_caps_malloc(newcap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!nb){ heap_caps_free(buf); close(fd); return -1; }
memcpy(nb,buf,total);
heap_caps_free(buf);
buf=nb;
capacity=newcap;
}
int r=lwip_recv(fd,buf+total,capacity-total-1,0);
if(r<=0){
if(r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT)){
ESP_LOGW(TAG,"http_get_raw timeout total=%d",total);
// On timeout, if fetching canceled, abort
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw timeout + canceled");
heap_caps_free(buf);
close(fd);
return -1;
}
// Continue loop LWIP will retry until EOF
// But if we already have header and content_len satisfied, break
if(header_end && content_len>=0 && !is_chunked && total-body_start >= content_len) break;
// If no header yet and timeout, treat as failure to avoid 15s hang
if(!header_end && total==0){
ESP_LOGE(TAG,"http_get_raw timeout before header");
heap_caps_free(buf);
close(fd);
return -1;
}
// Otherwise continue to try again
continue;
}
break;
}
total+=r;
buf[total]=0;
if(!header_end){
header_end=strstr(buf,"\r\n\r\n");
if(header_end){
body_start=(header_end-buf)+4;
if(strstr(buf,"chunked")) is_chunked=true;
char* cl=strstr(buf,"Content-Length:");
if(!cl) cl=strstr(buf,"content-length:");
if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } }
ESP_LOGI(TAG,"header_end chunked=%d cl=%d total=%d",is_chunked,content_len,total);
if(content_len>=0 && !is_chunked){
if(total-body_start >= content_len) break;
}
}
} else {
if(content_len>=0 && !is_chunked){
if(total-body_start >= content_len) break;
}
}
}
close(fd);
if(!header_end){ ESP_LOGE(TAG,"no header end total=%d",total); heap_caps_free(buf); return -1; }
if(strncmp(buf,"HTTP/1.1 200",12)!=0 && strncmp(buf,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"not 200: %.60s",buf); heap_caps_free(buf); return -1; }
int raw_len=total-body_start;
char* raw=buf+body_start;
char* final_body=NULL;
int final_len=0;
if(is_chunked){
final_body=heap_caps_malloc(200000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!final_body){ heap_caps_free(buf); return -1; }
int pos=0, out=0;
while(pos<raw_len){
char* crlf=strstr(raw+pos,"\r\n");
if(!crlf) break;
int off=crlf-(raw+pos);
if(off<=0 || off>16){ pos+=off+2; continue; }
char hex[16]={0};
memcpy(hex,raw+pos,off);
char* semi=strchr(hex,';');
if(semi) *semi=0;
long sz=strtol(hex,NULL,16);
if(sz==0) break;
pos+=off+2;
if(pos+sz>raw_len) sz=raw_len-pos;
if(out+sz>=200000) break;
memcpy(final_body+out,raw+pos,sz);
out+=sz;
pos+=sz;
if(pos+1<raw_len && raw[pos]=='\r' && raw[pos+1]=='\n') pos+=2;
}
final_body[out]=0;
final_len=out;
} else {
int bl=raw_len;
if(content_len>=0 && bl>content_len) bl=content_len;
final_body=heap_caps_malloc(bl+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!final_body){ heap_caps_free(buf); return -1; }
memcpy(final_body,raw,bl);
final_body[bl]=0;
final_len=bl;
}
heap_caps_free(buf);
*out_body=final_body;
ESP_LOGI(TAG,"GET ok %d",final_len);
return final_len;
}
bool fetch_data(){
char* js=NULL;
char url[320];
bool seasons_ok=false;
bool episodes_ok=false;
ESP_LOGI(TAG,"fetch seasons");
// Reduce payload via fields filter drastically lowers cJSON malloc pressure (internal heap low warning)
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num&fields=season_num",PB);
int len=http_get_raw(url,&js);
if(len>0 && js){
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(root){
cJSON* items=cJSON_GetObjectItem(root,"items");
if(cJSON_IsArray(items)){
G.season_cnt=0;
int n=cJSON_GetArraySize(items);
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
}
sort_seasons();
if(G.season_cnt>0) seasons_ok=true;
}
cJSON_Delete(root);
}
} else {
ESP_LOGW(TAG,"seasons fetch failed");
}
if(G.season_cnt==0){
ESP_LOGW(TAG,"seasons fallback");
for(int i=1;i<=37;i++) G.seasons[i-1].num=i;
G.season_cnt=37;
}
ESP_LOGI(TAG,"fetch episodes paginated to reduce internal heap (prev low 7KB)");
G.ep_cnt=0;
for(int page=1; page<=10; page++){
if(G.fetch_cancel_req) break;
if(G.ep_cnt>=MAX_EPS) break;
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=25&page=%d&sort=season_num,episode_num&fields=title,slug,season_num,episode_num,audio_url",PB,page);
len=http_get_raw(url,&js);
if(len<=0 || !js){
ESP_LOGW(TAG,"episodes page %d fetch failed",page);
if(js){ free(js); js=NULL; }
break;
}
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(!root){
ESP_LOGW(TAG,"episodes page %d JSON parse fail",page);
break;
}
cJSON* items=cJSON_GetObjectItem(root,"items");
if(!cJSON_IsArray(items)){
cJSON_Delete(root);
break;
}
int n=cJSON_GetArraySize(items);
if(n==0){
cJSON_Delete(root);
break;
}
ESP_LOGI(TAG,"episodes page %d got %d items (total so far %d)",page,n,G.ep_cnt);
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn||!en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
episodes_ok=true;
cJSON_Delete(root);
// Yield between pages to let MemoryChecker recover
vTaskDelay(pdMS_TO_TICKS(400));
if(n<25) break; // last page
}
// Final sort after all pages
if(G.ep_cnt>0) sort_eps();
if(G.ep_cnt==0){
ESP_LOGW(TAG,"episodes fallback");
for(int i=0;i<6;i++){
EpInfo* e=&G.eps[i];
snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1);
snprintf(e->title,sizeof(e->title),"Episode %d",i+1);
e->season=1; e->ep_num=i+1;
}
G.ep_cnt=6;
}
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
return (seasons_ok && episodes_ok);
}