be9229b80d
- Extracted app_context (global AppCtx G definition) - storage: path helpers, LRU, sort, fmt_time, per-episode resume map, state persistence - network: resolve_host, normalize URL, http_get_raw with growing PSRAM buffer, fetch_data - download: dl_ep_raw with progress tracking, cancel, overlay UI (show/hide/update) - player: audio device, stream, play_task with resume seek, start/select/next/prev with position save/restore - ui: build_ui, timers, season picker, button callbacks, periodic save - main: minimal glue, fetch_task, onShow/onHide, app registration - Preserves all previous fixes: download progress overlay, per-episode resume, robust download handling - Build verified: 111 undef, all exported
250 lines
9.3 KiB
C
250 lines
9.3 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 <unistd.h>
|
|
|
|
uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>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;
|
|
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
|
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
|
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
|
|
struct sockaddr_in sa; memset(&sa,0,sizeof(sa));
|
|
sa.sin_family=AF_INET; sa.sin_port=my_htons(port); sa.sin_addr.s_addr=ip;
|
|
struct timeval tv={8,0};
|
|
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
|
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
|
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
|
|
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; }
|
|
|
|
int capacity=65536;
|
|
char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
if(!buf){ ESP_LOGE(TAG,"PSRAM 64k 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){
|
|
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) 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;
|
|
}
|
|
|
|
void fetch_data(){
|
|
char* js=NULL;
|
|
char url[256];
|
|
ESP_LOGI(TAG,"fetch seasons");
|
|
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=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();
|
|
}
|
|
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");
|
|
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
|
|
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.ep_cnt=0;
|
|
int n=cJSON_GetArraySize(items);
|
|
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++;
|
|
}
|
|
sort_eps();
|
|
}
|
|
cJSON_Delete(root);
|
|
}
|
|
} else {
|
|
ESP_LOGW(TAG,"episodes fetch failed");
|
|
}
|
|
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);
|
|
}
|