#include "download.h" #include "storage.h" #include "network.h" #include "player.h" #include #include #include #include #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #include #include static void dl_cancel_cb(lv_event_t* e){ (void)e; ESP_LOGI(TAG,"DL cancel requested"); G.dl_cancel_req=true; if(G.dl_lbl_detail) lv_label_set_text(G.dl_lbl_detail,"Canceling..."); } void hide_dl_overlay(){ if(G.dl_overlay){ lv_obj_delete(G.dl_overlay); G.dl_overlay=NULL; G.dl_bar=NULL; G.dl_lbl_pct=NULL; G.dl_lbl_detail=NULL; G.dl_lbl_title=NULL; G.dl_lbl_sub=NULL; } G.downloading=false; } void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){ if(G.dl_overlay) hide_dl_overlay(); strncpy(G.dl_ep_title, ep_title ? ep_title : "Episode", sizeof(G.dl_ep_title)-1); strncpy(G.dl_slug, slug ? slug : "", sizeof(G.dl_slug)-1); G.dl_season=season; G.dl_ep_num=ep_num; G.dl_total=0; G.dl_expected=-1; G.dl_last_pct=-1; G.dl_cancel_req=false; G.downloading=true; G.dl_overlay = lv_obj_create(lv_scr_act()); lv_obj_set_size(G.dl_overlay, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(G.dl_overlay, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(G.dl_overlay, LV_OPA_70, 0); lv_obj_set_style_border_width(G.dl_overlay, 0, 0); lv_obj_set_style_pad_all(G.dl_overlay, 0, 0); lv_obj_set_style_radius(G.dl_overlay, 0, 0); lv_obj_remove_flag(G.dl_overlay, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* card = lv_obj_create(G.dl_overlay); lv_obj_set_size(card, 220, 210); lv_obj_center(card); lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); lv_obj_set_style_border_width(card, 1, 0); lv_obj_set_style_radius(card, 12, 0); lv_obj_set_style_pad_all(card, 12, 0); lv_obj_set_style_pad_row(card, 6, 0); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* lbl_head = lv_label_create(card); lv_label_set_text(lbl_head, "Downloading"); lv_obj_set_style_text_color(lbl_head, lv_color_hex(0xCDD6F4), 0); lv_obj_set_style_text_font(lbl_head, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0); G.dl_lbl_title = lv_label_create(card); lv_label_set_text(G.dl_lbl_title, G.dl_ep_title); lv_obj_set_width(G.dl_lbl_title, 190); lv_obj_set_style_text_color(G.dl_lbl_title, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_align(G.dl_lbl_title, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_long_mode(G.dl_lbl_title, LV_LABEL_LONG_WRAP); G.dl_lbl_sub = lv_label_create(card); char sub[64]; snprintf(sub, sizeof(sub), "S%02d E%02d • %s", season, ep_num, slug); lv_label_set_text(G.dl_lbl_sub, sub); lv_obj_set_style_text_color(G.dl_lbl_sub, lv_color_hex(0xA6ADC8), 0); lv_obj_set_style_text_font(G.dl_lbl_sub, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_obj_set_style_text_align(G.dl_lbl_sub, LV_TEXT_ALIGN_CENTER, 0); G.dl_bar = lv_bar_create(card); lv_obj_set_size(G.dl_bar, 190, 14); lv_bar_set_range(G.dl_bar, 0, 100); lv_bar_set_value(G.dl_bar, 0, LV_ANIM_OFF); lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x45475A), LV_PART_MAIN); lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); lv_obj_set_style_radius(G.dl_bar, 7, 0); G.dl_lbl_pct = lv_label_create(card); lv_label_set_text(G.dl_lbl_pct, "0%"); lv_obj_set_style_text_color(G.dl_lbl_pct, lv_color_hex(0xCDD6F4), 0); lv_obj_set_style_text_font(G.dl_lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0); G.dl_lbl_detail = lv_label_create(card); lv_label_set_text(G.dl_lbl_detail, "Starting…"); lv_obj_set_style_text_color(G.dl_lbl_detail, lv_color_hex(0xA6ADC8), 0); lv_obj_set_style_text_font(G.dl_lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_obj_set_style_text_align(G.dl_lbl_detail, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_width(G.dl_lbl_detail, 190); lv_label_set_long_mode(G.dl_lbl_detail, LV_LABEL_LONG_WRAP); lv_obj_t* btn_cancel = lv_button_create(card); lv_obj_set_size(btn_cancel, 90, 32); lv_obj_set_style_radius(btn_cancel, 8, 0); lv_obj_set_style_bg_color(btn_cancel, lv_color_hex(0x313244), 0); lv_obj_t* lbl_c = lv_label_create(btn_cancel); lv_label_set_text(lbl_c, "Cancel"); lv_obj_center(lbl_c); lv_obj_add_event_cb(btn_cancel, dl_cancel_cb, LV_EVENT_CLICKED, NULL); } void update_dl_overlay_ui(){ if(!G.downloading || !G.dl_overlay) return; if(G.dl_bar){ int pct=0; if(G.dl_expected>0){ pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected); if(pct<0) pct=0; if(pct>100) pct=100; } else { pct = G.dl_last_pct; if(pct<0) pct=0; } if(pct!=G.dl_last_pct){ G.dl_last_pct=pct; lv_bar_set_value(G.dl_bar, pct, LV_ANIM_OFF); } } if(G.dl_lbl_pct){ char b[16]; if(G.dl_expected>0){ int pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected); if(pct<0) pct=0; if(pct>100) pct=100; snprintf(b,sizeof(b),"%d%%",pct); } else { snprintf(b,sizeof(b),"%d KB",G.dl_total/1024); } lv_label_set_text(G.dl_lbl_pct,b); } if(G.dl_lbl_detail){ char d[64]; if(G.dl_expected>0){ int total_kb = G.dl_total/1024; int exp_kb = G.dl_expected/1024; if(exp_kb>1024){ snprintf(d,sizeof(d),"%d KB / %d KB (%d.%01d MB)", total_kb, exp_kb, exp_kb/1024, (exp_kb%1024)*10/1024); } else { snprintf(d,sizeof(d),"%d / %d KB", total_kb, exp_kb); } } else { snprintf(d,sizeof(d),"%d KB downloaded", G.dl_total/1024); } lv_label_set_text(G.dl_lbl_detail,d); } } bool dl_ep_raw(EpInfo* ep){ if(!ep) return false; if(strlen(ep->audio_url)==0){ ESP_LOGE(TAG,"dl no url for %s",ep->slug); return false; } char url_norm[512]; normalize_audio_url(ep->audio_url, url_norm, sizeof(url_norm)); ESP_LOGI(TAG,"dl url normalized: %s -> %s", ep->audio_url, url_norm); const char* url=url_norm; if(strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"dl url not http %s",url); return false; } const char* p=url+7; const char* slash=strchr(p,'/'); if(!slash){ ESP_LOGE(TAG,"dl no slash"); return false; } char hostport[128]={0}; size_t hlen=slash-p; if(hlen>=sizeof(hostport)){ ESP_LOGE(TAG,"dl hostport too long"); return false; } 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); } uint32_t ip=0; if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"dl ip fail %s",host); return false; } char final_path[300]; char tmp_path[320]; make_sd_path(final_path,sizeof(final_path),ep->slug); snprintf(tmp_path,sizeof(tmp_path),"%s.tmp",final_path); unlink(tmp_path); FILE* f=fopen(tmp_path,"wb"); if(!f){ ESP_LOGE(TAG,"dl fopen fail %s",tmp_path); return false; } int fd=lwip_socket(AF_INET,SOCK_STREAM,0); if(fd<0){ ESP_LOGE(TAG,"dl socket fail"); fclose(f); unlink(tmp_path); return false; } 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={15,0}; lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv)); lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv)); ESP_LOGI(TAG,"dl connecting %s:%d",host,port); if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){ ESP_LOGE(TAG,"dl connect fail %s:%d errno=%d",host,port,errno); fclose(f); close(fd); unlink(tmp_path); return false; } ESP_LOGI(TAG,"dl connected"); 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,"dl send fail errno=%d",errno); fclose(f); close(fd); unlink(tmp_path); return false; } ESP_LOGI(TAG,"dl request sent"); char* hdr=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!hdr){ fclose(f); close(fd); unlink(tmp_path); return false; } int hdr_len=0; bool header_done=false; int content_len=-1; int total=0; uint8_t* recv_buf=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!recv_buf){ heap_caps_free(hdr); fclose(f); close(fd); unlink(tmp_path); return false; } G.dl_total=0; G.dl_expected=-1; G.dl_last_pct=-1; while(!header_done){ if(G.dl_cancel_req){ ESP_LOGI(TAG,"dl canceled during header"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; } int r=lwip_recv(fd,(char*)recv_buf,4096,0); if(r<=0){ ESP_LOGE(TAG,"dl recv header fail r=%d errno=%d",r,errno); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; } if(hdr_len+r >= 8191){ ESP_LOGE(TAG,"dl hdr overflow"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; } memcpy(hdr+hdr_len, recv_buf, r); hdr_len+=r; hdr[hdr_len]=0; char* he=strstr(hdr,"\r\n\r\n"); if(he){ header_done=true; if(strncmp(hdr,"HTTP/1.1 200",12)!=0 && strncmp(hdr,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"dl not 200: %.120s",hdr); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; } char* cl=strstr(hdr,"Content-Length:"); if(!cl) cl=strstr(hdr,"content-length:"); if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } } ESP_LOGI(TAG,"dl header done cl=%d",content_len); G.dl_expected=content_len; int body_start = (he - hdr) + 4; int body_in_buf = hdr_len - body_start; if(body_in_buf>0){ fwrite(hdr+body_start,1,body_in_buf,f); total+=body_in_buf; G.dl_total=total; ESP_LOGI(TAG,"dl wrote initial %d",body_in_buf); } break; } } ESP_LOGI(TAG,"dl streaming body cl=%d",content_len); int consecutive_timeouts=0; const int max_timeouts=12; while(1){ if(G.dl_cancel_req){ ESP_LOGI(TAG,"dl canceled during body total=%d",total); heap_caps_free(hdr); heap_caps_free(recv_buf); close(fd); fclose(f); unlink(tmp_path); return false; } if(content_len>=0 && total>=content_len) break; int r=lwip_recv(fd,(char*)recv_buf,8192,0); if(r<0){ if(errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT || errno==0){ consecutive_timeouts++; ESP_LOGW(TAG,"dl timeout %d/%d total=%d/%d",consecutive_timeouts,max_timeouts,total,content_len); if(consecutive_timeouts>=max_timeouts){ ESP_LOGE(TAG,"dl too many timeouts, aborting"); break; } vTaskDelay(pdMS_TO_TICKS(200)); continue; } else { ESP_LOGE(TAG,"dl recv error r=%d errno=%d",r,errno); break; } } if(r==0){ ESP_LOGI(TAG,"dl EOF total=%d expected=%d",total,content_len); break; } consecutive_timeouts=0; fwrite(recv_buf,1,r,f); total+=r; G.dl_total=total; } heap_caps_free(hdr); heap_caps_free(recv_buf); close(fd); fclose(f); ESP_LOGI(TAG,"DL done total %d expected %d",total,content_len); if(G.dl_cancel_req){ ESP_LOGI(TAG,"DL canceled, removing tmp"); unlink(tmp_path); return false; } if(total<1024){ ESP_LOGE(TAG,"dl too small %d",total); unlink(tmp_path); return false; } if(content_len>=0 && total < content_len){ if(total < content_len * 95 / 100){ ESP_LOGE(TAG,"dl incomplete %d < %d (95%% threshold)",total,content_len); unlink(tmp_path); return false; } else { ESP_LOGW(TAG,"dl size slightly short %d vs %d, accepting",total,content_len); } } if(rename(tmp_path,final_path)!=0){ ESP_LOGE(TAG,"rename failed %s -> %s errno=%d",tmp_path,final_path,errno); unlink(tmp_path); return false; } ep->seen=true; return true; } bool dl_ep(EpInfo* ep){ if(!ep) return false; if(has_slug(ep->slug)) return true; ensure_dir(); lru_check(); return dl_ep_raw(ep); } void dl_task(void* arg){ int idx = (int)(intptr_t)arg; G.downloading=true; G.dl_cancel_req=false; if(idx<0||idx>=G.ep_cnt){ G.downloading=false; G.dl_handle=NULL; vTaskDelete(NULL); return; } EpInfo* ep=&G.eps[idx]; ESP_LOGI(TAG,"DL start %s url %s",ep->slug,ep->audio_url); bool ok=dl_ep(ep); ESP_LOGI(TAG,"DL end %s ok %d",ep->slug,ok); tt_lvgl_lock(portMAX_DELAY); hide_dl_overlay(); if(G.lbl_status){ if(ok) lv_label_set_text(G.lbl_status,"DL done, playing..."); else { if(G.dl_cancel_req) lv_label_set_text(G.lbl_status,"DL canceled"); else lv_label_set_text(G.lbl_status,"DL failed – tap Play to retry"); } } tt_lvgl_unlock(); G.downloading=false; G.dl_handle=NULL; if(ok){ start_idx_internal(idx); } vTaskDelete(NULL); }