Krixter commited on
Commit
1950c70
Β·
verified Β·
1 Parent(s): add71fd

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +461 -0
app.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import os
3
+ import tempfile
4
+ import requests
5
+ import subprocess
6
+ import zipfile
7
+ import shutil
8
+ import time
9
+ import asyncio
10
+ import re
11
+ import hashlib
12
+ from bs4 import BeautifulSoup
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from urllib.parse import urljoin
15
+ import PyPDF2
16
+
17
+ # ==========================================
18
+ # βš™οΈ CONFIGURATION & CONSTANTS
19
+ # ==========================================
20
+ THREADS = 10
21
+ download_door = asyncio.Semaphore(1)
22
+ kitchen_door = asyncio.Semaphore(1)
23
+
24
+ CACHE_DIR = os.path.join(os.getcwd(), "AudioNovel_Cache")
25
+ os.makedirs(CACHE_DIR, exist_ok=True)
26
+
27
+ # ==========================================
28
+ # πŸ› οΈ HELPER FUNCTIONS
29
+ # ==========================================
30
+ def sanitize_filename(title):
31
+ return re.sub(r'[\\/*?:"<>|]', "", title).strip()
32
+
33
+ def get_chunk_hash(text):
34
+ return hashlib.md5(text.encode('utf-8')).hexdigest()
35
+
36
+ def smart_sanitize(text):
37
+ text = re.sub(r'http[s]?://\S+', '', text)
38
+ patterns = [
39
+ r'(?i)(please\s+)?vote for.*?(power stones|tickets)',
40
+ r'(?i)support me on patreon',
41
+ r'(?i)join my discord',
42
+ r'(?i)ko-fi\.com',
43
+ r'(?i)buy me a coffee',
44
+ r'(?i)author\'s note[s]?[\s\S]*',
45
+ r'(?i)translator\'s note[s]?[\s\S]*'
46
+ ]
47
+ for p in patterns:
48
+ text = re.sub(p, '', text)
49
+ return re.sub(r'\n{3,}', '\n\n', text).strip()
50
+
51
+ def extract_novel_info(main_url, headers):
52
+ try:
53
+ # Bypass cookies for 18+ Mature walls
54
+ bypass_cookies = {'isAdult': '1', 'adult': 'true', 'over18': '1', 'age_verified': '1'}
55
+
56
+ response = requests.get(main_url, headers=headers, cookies=bypass_cookies, timeout=15)
57
+ response.raise_for_status()
58
+ soup = BeautifulSoup(response.text, 'html.parser')
59
+
60
+ # 1. Get Title
61
+ title = "Unknown_Novel"
62
+ h1_tag = soup.find('h1')
63
+ if h1_tag:
64
+ title = h1_tag.get_text(strip=True)
65
+ else:
66
+ title = soup.title.get_text(strip=True).split('-')[0]
67
+ safe_title = sanitize_filename(title)
68
+
69
+ # 2. Get Cover Art
70
+ cover_url = None
71
+ og_img = soup.find('meta', property='og:image')
72
+ if og_img and og_img.get('content'):
73
+ cover_url = og_img['content']
74
+
75
+ # 3. Get Chapter Links
76
+ links = []
77
+ morelist_div = soup.find('div', id='morelist')
78
+
79
+ if morelist_div:
80
+ # Standard Novelhall Layout
81
+ for a_tag in morelist_div.find_all('a', href=True):
82
+ links.append(urljoin(main_url, a_tag['href']))
83
+ else:
84
+ # SUPER RADAR: If 'morelist' is hidden (e.g. adult novels or short stories)
85
+ for a_tag in soup.find_all('a', href=True):
86
+ href = a_tag['href']
87
+ full_url = urljoin(main_url, href)
88
+ # Filter out obvious junk links, keep things that look like chapters
89
+ if full_url.startswith(main_url) and full_url != main_url and len(href) > 5:
90
+ if full_url not in links:
91
+ links.append(full_url)
92
+
93
+ if not links:
94
+ return None, [], None, "No chapter links could be found on the page. The site layout might have changed."
95
+
96
+ return safe_title, links, cover_url, None
97
+ except requests.exceptions.HTTPError as e:
98
+ return None, [], None, f"HTTP Error {e.response.status_code}: The website blocked the request or the page doesn't exist."
99
+ except Exception as e:
100
+ return None, [], None, f"Extraction failed: {str(e)}"
101
+
102
+ def extract_local_text(path, temp_dir):
103
+ content = ""
104
+ ext = os.path.splitext(path)[1].lower()
105
+ try:
106
+ if ext == '.txt':
107
+ with open(path, 'r', encoding='utf-8', errors='ignore') as f:
108
+ content = ' '.join(f.read().split())
109
+ elif ext == '.epub':
110
+ with zipfile.ZipFile(path, 'r') as z:
111
+ for filename in z.namelist():
112
+ if filename.endswith(('.html', '.xhtml')):
113
+ with z.open(filename) as f:
114
+ soup = BeautifulSoup(f.read(), 'html.parser')
115
+ clean_part = ' '.join(soup.get_text(separator=' ').split())
116
+ if clean_part: content += clean_part + " "
117
+ elif ext == '.pdf':
118
+ reader = PyPDF2.PdfReader(path)
119
+ for page in reader.pages:
120
+ txt = page.extract_text()
121
+ if txt: content += ' '.join(txt.split()) + " "
122
+ except Exception as e:
123
+ return None
124
+
125
+ if not content: return None
126
+ out = os.path.join(temp_dir, f"extracted_{int(time.time()*1000)}.txt")
127
+ with open(out, 'w', encoding='utf-8') as f: f.write(content)
128
+ return out
129
+
130
+ # ==========================================
131
+ # πŸ—£οΈ TEXT-TO-SPEECH ENGINE
132
+ # ==========================================
133
+ class GoogleTTSEngine:
134
+ def split_text(self, text, max_len=200):
135
+ chunks = []
136
+ while text:
137
+ if len(text) <= max_len:
138
+ chunks.append(text.strip())
139
+ break
140
+ best_i = -1
141
+ for punct in [". ", "! ", "? ", "; "]:
142
+ i = text[:max_len].rfind(punct)
143
+ if i > best_i: best_i = i
144
+ if best_i == -1: best_i = text[:max_len].rfind(" ")
145
+ if best_i == -1: best_i = max_len
146
+ else: best_i += 1
147
+ chunks.append(text[:best_i].strip())
148
+ text = text[best_i:].lstrip()
149
+ return chunks
150
+
151
+ def strip_id3(self, mp3_data):
152
+ if mp3_data[:3] == b"ID3":
153
+ size = ((mp3_data[6] & 0x7F) << 21 | (mp3_data[7] & 0x7F) << 14 |
154
+ (mp3_data[8] & 0x7F) << 7 | (mp3_data[9] & 0x7F))
155
+ mp3_data = mp3_data[10 + size:]
156
+ if len(mp3_data) > 128 and mp3_data[-128:-125] == b"TAG":
157
+ mp3_data = mp3_data[:-128]
158
+ return mp3_data
159
+
160
+ def download_chunk(self, text):
161
+ chunk_hash = get_chunk_hash(text)
162
+ cache_path = os.path.join(CACHE_DIR, f"{chunk_hash}.mp3")
163
+
164
+ if os.path.exists(cache_path):
165
+ with open(cache_path, "rb") as f:
166
+ return f.read()
167
+
168
+ url = "https://translate.google.com/translate_tts"
169
+ params = {"ie": "UTF-8", "q": text, "tl": "en-IN", "client": "tw-ob"}
170
+ headers = {"User-Agent": "Mozilla/5.0"}
171
+ for _ in range(5):
172
+ try:
173
+ r = requests.get(url, params=params, headers=headers, timeout=5)
174
+ if r.status_code == 200:
175
+ clean_audio = self.strip_id3(r.content)
176
+ with open(cache_path, "wb") as f: f.write(clean_audio)
177
+ return clean_audio
178
+ except: time.sleep(0.5)
179
+ return None
180
+
181
+ def sync_download_worker(text_file, temp_dir, progress_obj, book_idx, total_books, base_name):
182
+ book_label = f"[{book_idx}/{total_books}]"
183
+ engine = GoogleTTSEngine()
184
+
185
+ with open(text_file, 'r', encoding='utf-8') as f:
186
+ raw_text = f.read()
187
+
188
+ full_text = smart_sanitize(raw_text)
189
+ chunks = engine.split_text(full_text)
190
+ total_chunks = len(chunks)
191
+
192
+ if total_chunks == 0:
193
+ return None
194
+
195
+ chunk_dir = os.path.join(temp_dir, "chunks")
196
+ os.makedirs(chunk_dir, exist_ok=True)
197
+
198
+ start_time = time.time()
199
+ completed = 0
200
+
201
+ with ThreadPoolExecutor(max_workers=THREADS) as pool:
202
+ futures = {pool.submit(engine.download_chunk, chunk): i for i, chunk in enumerate(chunks)}
203
+ for f in as_completed(futures):
204
+ idx = futures[f]
205
+ data = f.result()
206
+ if data:
207
+ with open(os.path.join(chunk_dir, f"{idx:05d}.mp3"), "wb") as af: af.write(data)
208
+ completed += 1
209
+ elapsed = time.time() - start_time
210
+ avg = elapsed / completed
211
+ eta = avg * (total_chunks - completed)
212
+ progress_obj((completed/total_chunks), desc=f"πŸ“₯ {book_label} TTS Audio: {completed}/{total_chunks} | ⏳ ETA: {int(eta//60):02d}:{int(eta%60):02d}")
213
+
214
+ merged_path = os.path.join(temp_dir, "raw.mp3")
215
+ with open(merged_path, 'wb') as outfile:
216
+ for i in range(len(chunks)):
217
+ p = os.path.join(chunk_dir, f"{i:05d}.mp3")
218
+ if os.path.exists(p):
219
+ with open(p, 'rb') as infile: shutil.copyfileobj(infile, outfile)
220
+ return merged_path
221
+
222
+ def sync_ffmpeg_worker(merged_path, final_output_m4b, temp_dir, speed, title, cover_url):
223
+ cover_path = os.path.join(temp_dir, "cover.jpg")
224
+ has_cover = False
225
+
226
+ if cover_url:
227
+ try:
228
+ r = requests.get(cover_url, timeout=5)
229
+ if r.status_code == 200:
230
+ with open(cover_path, 'wb') as f: f.write(r.content)
231
+ has_cover = True
232
+ except: pass
233
+
234
+ cmd = ["ffmpeg", "-y", "-i", merged_path]
235
+
236
+ if has_cover:
237
+ cmd.extend(["-i", cover_path, "-map", "0:a", "-map", "1", "-c:v", "copy", "-disposition:v", "attached_pic"])
238
+ else:
239
+ cmd.extend(["-map", "0:a"])
240
+
241
+ cmd.extend([
242
+ "-af", f"silenceremove=1:0:-50dB,atempo={speed}",
243
+ "-c:a", "aac", "-b:a", "64k",
244
+ "-metadata", f"title={title}",
245
+ "-metadata", "artist=Ultimate AudioNovel Studio",
246
+ "-metadata", "album=Web Novels",
247
+ "-threads", "0",
248
+ final_output_m4b
249
+ ])
250
+
251
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
252
+ return final_output_m4b
253
+
254
+ async def convert_text_to_audio(text_file, title, cover_url, speed, progress_obj, book_idx, total_books):
255
+ temp_dir = tempfile.mkdtemp()
256
+
257
+ try:
258
+ async with download_door:
259
+ merged_path = await asyncio.to_thread(sync_download_worker, text_file, temp_dir, progress_obj, book_idx, total_books, title)
260
+
261
+ if not merged_path:
262
+ return None
263
+
264
+ final_output_m4b = os.path.join(tempfile.gettempdir(), f"{title}_Audiobook.m4b")
265
+
266
+ progress_obj(1.0, desc=f"πŸš€ Packaging {title} into .m4b format...")
267
+ async with kitchen_door:
268
+ output_file = await asyncio.to_thread(sync_ffmpeg_worker, merged_path, final_output_m4b, temp_dir, speed, title, cover_url)
269
+ return output_file
270
+ finally:
271
+ # Cleanup temp files safely
272
+ try: shutil.rmtree(temp_dir)
273
+ except: pass
274
+
275
+ async def master_tts_pipeline(text_info_list, speed, progress_obj):
276
+ total_books = len(text_info_list)
277
+ finished_files = []
278
+
279
+ for idx, (txt_path, title, cover_url) in enumerate(text_info_list, 1):
280
+ m4b_path = await convert_text_to_audio(txt_path, title, cover_url, speed, progress_obj, idx, total_books)
281
+ if m4b_path:
282
+ finished_files.append(m4b_path)
283
+ yield finished_files
284
+
285
+ if len(finished_files) > 1:
286
+ zip_path = os.path.join(tempfile.gettempdir(), "AudioBook_MasterBatch.zip")
287
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
288
+ for f in finished_files: zipf.write(f, os.path.basename(f))
289
+ finished_files.insert(0, zip_path)
290
+ yield finished_files
291
+
292
+ # ==========================================
293
+ # πŸš€ CORE ENTRY POINTS
294
+ # ==========================================
295
+ async def process_links_to_audio(input_mode, input_text, speed, progress=gr.Progress()):
296
+ if not input_text.strip():
297
+ yield [], "❌ Error: No input provided."
298
+ return
299
+
300
+ headers = {'User-Agent': 'Mozilla/5.0'}
301
+ bypass_cookies = {'isAdult': '1', 'adult': 'true', 'over18': '1', 'age_verified': '1'}
302
+
303
+ input_urls = [line.strip() for line in input_text.split('\n') if line.strip()]
304
+ text_info_list = []
305
+ overall_status = ""
306
+
307
+ if input_mode == "Main Novel Page URLs (Batch Supported)":
308
+ for novel_idx, main_url in enumerate(input_urls, 1):
309
+ progress(0, desc=f"Extracting Info: Novel {novel_idx}/{len(input_urls)}")
310
+ novel_title, links, cover_url, err = extract_novel_info(main_url, headers)
311
+
312
+ if err or not links:
313
+ overall_status += f"❌ Failed {main_url} - {err}\n"
314
+ continue
315
+
316
+ compiled_text = ""
317
+ for idx, link in progress.tqdm(enumerate(links, 1), total=len(links), desc=f"Scraping '{novel_title}'"):
318
+ try:
319
+ response = requests.get(link, headers=headers, cookies=bypass_cookies, timeout=15)
320
+ soup = BeautifulSoup(response.text, 'html.parser')
321
+ lines = []
322
+
323
+ if 'novelhall.com' in link:
324
+ header_div = soup.find('div', class_='single-header')
325
+ if header_div and header_div.find('h1'): lines.append(header_div.find('h1').get_text(strip=True))
326
+ content_div = soup.find('div', id='htmlContent')
327
+ if content_div:
328
+ for br in content_div.find_all('br'): br.replace_with('\n')
329
+ for p in content_div.get_text(separator='\n', strip=True).split('\n'):
330
+ if p.strip(): lines.append(p.strip())
331
+ elif 'novelbin.com' in link:
332
+ content_div = soup.find('div', id='chr-content')
333
+ if content_div:
334
+ h4 = content_div.find('h4')
335
+ if h4: lines.append(h4.get_text(strip=True))
336
+ for p in content_div.find_all('p'):
337
+ if p.get_text(strip=True): lines.append(p.get_text(strip=True))
338
+
339
+ compiled_text += '\n\n'.join(lines) + '\n\n\n'
340
+ except Exception as e: pass
341
+ time.sleep(0.2)
342
+
343
+ if not compiled_text.strip():
344
+ overall_status += f"❌ Failed {novel_title} - Could not extract chapter text.\n"
345
+ continue
346
+
347
+ temp_file = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8', suffix='.txt')
348
+ temp_file.write(compiled_text)
349
+ temp_file.close()
350
+ text_info_list.append((temp_file.name, novel_title, cover_url))
351
+ overall_status += f"βœ… Scraped {novel_title} ({len(links)} chapters)\n"
352
+
353
+ else: # Custom Chapters
354
+ novel_title = "Custom_Scraped_Chapters"
355
+ compiled_text = ""
356
+ for idx, link in progress.tqdm(enumerate(input_urls, 1), total=len(input_urls), desc="Scraping Custom Chapters"):
357
+ try:
358
+ response = requests.get(link, headers=headers, cookies=bypass_cookies, timeout=15)
359
+ soup = BeautifulSoup(response.text, 'html.parser')
360
+ lines = []
361
+ content_div = soup.find('div', id='htmlContent') or soup.find('div', id='chr-content')
362
+ if content_div:
363
+ for br in content_div.find_all('br'): br.replace_with('\n')
364
+ for p in content_div.get_text(separator='\n', strip=True).split('\n'):
365
+ if p.strip(): lines.append(p.strip())
366
+ compiled_text += '\n\n'.join(lines) + '\n\n\n'
367
+ except Exception as e: pass
368
+ time.sleep(0.2)
369
+
370
+ if compiled_text.strip():
371
+ temp_file = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8', suffix='.txt')
372
+ temp_file.write(compiled_text)
373
+ temp_file.close()
374
+ text_info_list.append((temp_file.name, novel_title, None))
375
+ overall_status += f"βœ… Scraped {len(input_urls)} Custom Chapters\n"
376
+ else:
377
+ overall_status += "❌ Failed - Could not extract any text from the provided links.\n"
378
+
379
+ if not text_info_list:
380
+ yield [], overall_status
381
+ return
382
+
383
+ overall_status += "βš™οΈ Initiating TTS Audio Conversion...\n"
384
+ yield [], overall_status
385
+
386
+ async for audio_files in master_tts_pipeline(text_info_list, speed, progress):
387
+ yield audio_files, overall_status + f"βœ… Audiobook Process Complete!"
388
+
389
+ async def process_files_to_audio(file_paths, speed, progress=gr.Progress()):
390
+ if not file_paths:
391
+ yield [], "❌ Error: No files uploaded."
392
+ return
393
+
394
+ if not isinstance(file_paths, list): file_paths = [file_paths]
395
+ text_info_list = []
396
+
397
+ for i, path in enumerate(file_paths):
398
+ progress(i/len(file_paths), desc="Extracting text from local files...")
399
+ temp_dir = tempfile.mkdtemp()
400
+ txt_path = extract_local_text(path, temp_dir)
401
+ if txt_path:
402
+ base_name = sanitize_filename(os.path.splitext(os.path.basename(path))[0])
403
+ text_info_list.append((txt_path, base_name, None))
404
+
405
+ if not text_info_list:
406
+ yield [], "❌ Error: Could not extract readable text from the uploaded files."
407
+ return
408
+
409
+ overall_status = "βœ… Local files parsed. Initiating TTS Audio Conversion...\n"
410
+ yield [], overall_status
411
+
412
+ async for audio_files in master_tts_pipeline(text_info_list, speed, progress):
413
+ yield audio_files, overall_status + f"βœ… Audiobook Process Complete!"
414
+
415
+ # ==========================================
416
+ # 🎨 GRADIO UI
417
+ # ==========================================
418
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo")) as app:
419
+ gr.Markdown("# 🎧 The Ultimate AudioNovel Studio")
420
+ gr.Markdown("Transform Web Novels into True **.m4b Audiobooks** featuring Cover Art Injection, Save-State Caching, and Smart Text Sanitization!")
421
+
422
+ with gr.Tabs():
423
+ with gr.Tab("🌐 Link to Audio (Web Scraper)"):
424
+ with gr.Row():
425
+ with gr.Column(scale=1):
426
+ input_mode = gr.Radio(
427
+ choices=["Main Novel Page URLs (Batch Supported)", "List of Chapter Links"],
428
+ value="Main Novel Page URLs (Batch Supported)",
429
+ label="Source Type"
430
+ )
431
+ links_input = gr.Textbox(lines=8, label="Paste Links Here (One per line)")
432
+ speed_slider_1 = gr.Slider(minimum=1.0, maximum=3.0, value=1.6, step=0.1, label="Voice Speedup")
433
+ with gr.Row():
434
+ start_btn_1 = gr.Button("πŸš€ Scrape & Convert", variant="primary")
435
+ stop_btn_1 = gr.Button("πŸ›‘ Stop", variant="stop")
436
+
437
+ with gr.Column(scale=1):
438
+ status_output_1 = gr.Textbox(label="Status Log", interactive=False, lines=4)
439
+ file_output_1 = gr.File(label="πŸ“₯ Download Ready Audiobook (.m4b)", file_count="multiple", interactive=False)
440
+
441
+ process_event_1 = start_btn_1.click(fn=process_links_to_audio, inputs=[input_mode, links_input, speed_slider_1], outputs=[file_output_1, status_output_1])
442
+ stop_btn_1.click(fn=None, inputs=None, outputs=None, cancels=[process_event_1])
443
+
444
+ with gr.Tab("πŸ“‚ File to Audio (Local Converter)"):
445
+ with gr.Row():
446
+ with gr.Column(scale=1):
447
+ file_in = gr.File(label="Upload Books (.txt, .epub, .pdf)", file_count="multiple")
448
+ speed_slider_2 = gr.Slider(minimum=1.0, maximum=3.0, value=1.6, step=0.1, label="Voice Speedup")
449
+ with gr.Row():
450
+ start_btn_2 = gr.Button("πŸš€ Convert Files to Audio", variant="primary")
451
+ stop_btn_2 = gr.Button("πŸ›‘ Stop", variant="stop")
452
+
453
+ with gr.Column(scale=1):
454
+ status_output_2 = gr.Textbox(label="Status Log", interactive=False, lines=4)
455
+ file_output_2 = gr.File(label="πŸ“₯ Download Ready Audiobook (.m4b)", file_count="multiple", interactive=False)
456
+
457
+ process_event_2 = start_btn_2.click(fn=process_files_to_audio, inputs=[file_in, speed_slider_2], outputs=[file_output_2, status_output_2])
458
+ stop_btn_2.click(fn=None, inputs=None, outputs=None, cancels=[process_event_2])
459
+
460
+ if __name__ == "__main__":
461
+ app.queue().launch()