3778cb3399
- 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...'
331 lines
12 KiB
C
331 lines
12 KiB
C
#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);
|
||
}
|