Update app.py
Browse files
app.py
CHANGED
|
@@ -114,45 +114,65 @@ def decrypt_file(input_path, output_path, key, nonce):
|
|
| 114 |
outfile.write(decrypted_data)
|
| 115 |
|
| 116 |
def parse_dash_manifest(manifest_data):
|
| 117 |
-
"""Parse DASH manifest XML and extract segment URLs"""
|
| 118 |
manifest_str = manifest_data.decode('utf-8')
|
| 119 |
-
|
| 120 |
base_url = ""
|
| 121 |
segments = []
|
| 122 |
media_template = ""
|
| 123 |
start_number = 1
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
try:
|
| 126 |
root = ET.fromstring(manifest_str)
|
| 127 |
-
|
| 128 |
for elem in root.iter():
|
| 129 |
tag = elem.tag
|
| 130 |
if '}' in tag:
|
| 131 |
tag = tag.split('}')[1]
|
| 132 |
-
|
| 133 |
if tag == 'BaseURL' or tag.lower() == 'baseurl':
|
| 134 |
base_url = elem.text
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if tag == 'SegmentTemplate' or tag.lower() == 'segmenttemplate':
|
| 137 |
media_template = elem.get('media', '')
|
|
|
|
| 138 |
start_number = int(elem.get('startNumber', '1'))
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
for timeline in elem.iter():
|
| 141 |
tl_tag = timeline.tag
|
| 142 |
if '}' in tl_tag:
|
| 143 |
tl_tag = tl_tag.split('}')[1]
|
| 144 |
-
|
| 145 |
if tl_tag == 'SegmentTimeline' or tl_tag.lower() == 'segmenttimeline':
|
| 146 |
total_segments = 0
|
| 147 |
for s_elem in timeline.iter():
|
| 148 |
s_tag = s_elem.tag
|
| 149 |
if '}' in s_tag:
|
| 150 |
s_tag = s_tag.split('}')[1]
|
| 151 |
-
|
| 152 |
if s_tag == 'S' or s_tag.lower() == 's':
|
| 153 |
repeat = int(s_elem.get('r', '0'))
|
| 154 |
total_segments += repeat + 1
|
| 155 |
-
|
| 156 |
if media_template and total_segments > 0:
|
| 157 |
for i in range(start_number, start_number + total_segments):
|
| 158 |
segment_url = media_template.replace('$Number$', str(i))
|
|
@@ -160,34 +180,63 @@ def parse_dash_manifest(manifest_data):
|
|
| 160 |
print(f"β
Generated {len(segments)} segment URLs from template")
|
| 161 |
except Exception as e:
|
| 162 |
print(f"β οΈ Error parsing DASH manifest: {e}")
|
| 163 |
-
|
| 164 |
if not segments:
|
| 165 |
print("β οΈ Trying regex parsing...")
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
template_match = re.search(r'media="([^"]+\$Number\$[^"]+)"', manifest_str)
|
| 169 |
if template_match:
|
| 170 |
media_template = template_match.group(1)
|
| 171 |
-
|
| 172 |
start_num_match = re.search(r'startNumber="(\d+)"', manifest_str)
|
| 173 |
if start_num_match:
|
| 174 |
start_number = int(start_num_match.group(1))
|
| 175 |
-
|
| 176 |
timeline_matches = re.findall(r'<S[^>]*d="(\d+)"[^>]*r="(\d+)"', manifest_str)
|
| 177 |
total_segments = 0
|
| 178 |
for d, r in timeline_matches:
|
| 179 |
total_segments += int(r) + 1
|
| 180 |
-
|
| 181 |
single_segments = re.findall(r'<S[^>]*d="(\d+)"[^>]*[^r][^>]*>', manifest_str)
|
| 182 |
total_segments += len(single_segments)
|
| 183 |
-
|
| 184 |
if total_segments > 0:
|
| 185 |
for i in range(start_number, start_number + total_segments):
|
| 186 |
segment_url = media_template.replace('$Number$', str(i))
|
| 187 |
segments.append(segment_url)
|
| 188 |
print(f"β
Generated {len(segments)} segment URLs with regex")
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
def parse_bt_manifest(manifest_data):
|
| 193 |
"""Parse BT manifest (JSON format)"""
|
|
@@ -283,6 +332,7 @@ async def download_track(track_id: int, quality: str = "LOSSLESS"):
|
|
| 283 |
|
| 284 |
if is_lossless:
|
| 285 |
security_token = data.get('securityToken')
|
|
|
|
| 286 |
key = None
|
| 287 |
nonce = None
|
| 288 |
|
|
@@ -292,18 +342,25 @@ async def download_track(track_id: int, quality: str = "LOSSLESS"):
|
|
| 292 |
|
| 293 |
download_dir = "./downloads"
|
| 294 |
os.makedirs(download_dir, exist_ok=True)
|
| 295 |
-
|
| 296 |
-
|
| 297 |
|
| 298 |
try:
|
| 299 |
print("π¦ Parsing DASH manifest...")
|
| 300 |
-
base_url, segments = parse_dash_manifest(manifest_data)
|
| 301 |
-
print(f"β
Found {len(segments)} segments")
|
| 302 |
|
| 303 |
print("β¬οΈ Downloading segments...")
|
|
|
|
| 304 |
for i, seg in enumerate(segments):
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
max_retries = 3
|
| 309 |
success = False
|
|
@@ -326,27 +383,94 @@ async def download_track(track_id: int, quality: str = "LOSSLESS"):
|
|
| 326 |
|
| 327 |
print(f" Segment {i+1}/{len(segments)} downloaded")
|
| 328 |
|
| 329 |
-
print("
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
output_path = os.path.join(download_dir, output_filename)
|
|
|
|
| 332 |
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
-
if
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
else:
|
| 341 |
-
|
| 342 |
-
with open(decrypted_path, 'wb') as df:
|
| 343 |
-
df.write(f.read())
|
| 344 |
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
print(f"β
Download complete: {output_path}")
|
| 349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
file_size = os.path.getsize(output_path)
|
| 351 |
|
| 352 |
return {
|
|
@@ -362,7 +486,7 @@ async def download_track(track_id: int, quality: str = "LOSSLESS"):
|
|
| 362 |
"isrc": isrc,
|
| 363 |
"quality": audio_quality,
|
| 364 |
"fileSize": file_size,
|
| 365 |
-
"format":
|
| 366 |
},
|
| 367 |
"downloadUrl": f"/download_file/{output_filename}",
|
| 368 |
"message": "File downloaded and ready for download"
|
|
|
|
| 114 |
outfile.write(decrypted_data)
|
| 115 |
|
| 116 |
def parse_dash_manifest(manifest_data):
|
| 117 |
+
"""Parse DASH manifest XML and extract segment URLs and audio format"""
|
| 118 |
manifest_str = manifest_data.decode('utf-8')
|
| 119 |
+
|
| 120 |
base_url = ""
|
| 121 |
segments = []
|
| 122 |
media_template = ""
|
| 123 |
start_number = 1
|
| 124 |
+
audio_format = "flac"
|
| 125 |
+
codecs_detected = ""
|
| 126 |
+
|
| 127 |
try:
|
| 128 |
root = ET.fromstring(manifest_str)
|
| 129 |
+
|
| 130 |
for elem in root.iter():
|
| 131 |
tag = elem.tag
|
| 132 |
if '}' in tag:
|
| 133 |
tag = tag.split('}')[1]
|
| 134 |
+
|
| 135 |
if tag == 'BaseURL' or tag.lower() == 'baseurl':
|
| 136 |
base_url = elem.text
|
| 137 |
+
|
| 138 |
+
if tag == 'Representation' or tag.lower() == 'representation':
|
| 139 |
+
codec = elem.get('codecs', '').upper()
|
| 140 |
+
codecs_detected = codec
|
| 141 |
+
if codec:
|
| 142 |
+
print(f" Found Representation with codecs: '{codec}'")
|
| 143 |
+
if 'MP4A' in codec or 'AAC' in codec:
|
| 144 |
+
audio_format = "m4a"
|
| 145 |
+
elif 'FLAC' in codec:
|
| 146 |
+
audio_format = "flac"
|
| 147 |
+
elif 'EC3' in codec or 'EAC3' in codec:
|
| 148 |
+
audio_format = "eac3"
|
| 149 |
+
print(f" Detected audio format: {audio_format}")
|
| 150 |
+
|
| 151 |
if tag == 'SegmentTemplate' or tag.lower() == 'segmenttemplate':
|
| 152 |
media_template = elem.get('media', '')
|
| 153 |
+
initialization = elem.get('initialization', '')
|
| 154 |
start_number = int(elem.get('startNumber', '1'))
|
| 155 |
|
| 156 |
+
if initialization:
|
| 157 |
+
segments.append(initialization)
|
| 158 |
+
print(f"β
Added initialization segment: {initialization}")
|
| 159 |
+
|
| 160 |
for timeline in elem.iter():
|
| 161 |
tl_tag = timeline.tag
|
| 162 |
if '}' in tl_tag:
|
| 163 |
tl_tag = tl_tag.split('}')[1]
|
| 164 |
+
|
| 165 |
if tl_tag == 'SegmentTimeline' or tl_tag.lower() == 'segmenttimeline':
|
| 166 |
total_segments = 0
|
| 167 |
for s_elem in timeline.iter():
|
| 168 |
s_tag = s_elem.tag
|
| 169 |
if '}' in s_tag:
|
| 170 |
s_tag = s_tag.split('}')[1]
|
| 171 |
+
|
| 172 |
if s_tag == 'S' or s_tag.lower() == 's':
|
| 173 |
repeat = int(s_elem.get('r', '0'))
|
| 174 |
total_segments += repeat + 1
|
| 175 |
+
|
| 176 |
if media_template and total_segments > 0:
|
| 177 |
for i in range(start_number, start_number + total_segments):
|
| 178 |
segment_url = media_template.replace('$Number$', str(i))
|
|
|
|
| 180 |
print(f"β
Generated {len(segments)} segment URLs from template")
|
| 181 |
except Exception as e:
|
| 182 |
print(f"β οΈ Error parsing DASH manifest: {e}")
|
| 183 |
+
|
| 184 |
if not segments:
|
| 185 |
print("β οΈ Trying regex parsing...")
|
| 186 |
+
|
| 187 |
+
init_match = re.search(r'initialization="([^"]+)"', manifest_str)
|
| 188 |
+
if init_match:
|
| 189 |
+
initialization = init_match.group(1)
|
| 190 |
+
segments.append(initialization)
|
| 191 |
+
print(f"β
Added initialization segment with regex: {initialization}")
|
| 192 |
+
|
| 193 |
template_match = re.search(r'media="([^"]+\$Number\$[^"]+)"', manifest_str)
|
| 194 |
if template_match:
|
| 195 |
media_template = template_match.group(1)
|
| 196 |
+
|
| 197 |
start_num_match = re.search(r'startNumber="(\d+)"', manifest_str)
|
| 198 |
if start_num_match:
|
| 199 |
start_number = int(start_num_match.group(1))
|
| 200 |
+
|
| 201 |
timeline_matches = re.findall(r'<S[^>]*d="(\d+)"[^>]*r="(\d+)"', manifest_str)
|
| 202 |
total_segments = 0
|
| 203 |
for d, r in timeline_matches:
|
| 204 |
total_segments += int(r) + 1
|
| 205 |
+
|
| 206 |
single_segments = re.findall(r'<S[^>]*d="(\d+)"[^>]*[^r][^>]*>', manifest_str)
|
| 207 |
total_segments += len(single_segments)
|
| 208 |
+
|
| 209 |
if total_segments > 0:
|
| 210 |
for i in range(start_number, start_number + total_segments):
|
| 211 |
segment_url = media_template.replace('$Number$', str(i))
|
| 212 |
segments.append(segment_url)
|
| 213 |
print(f"β
Generated {len(segments)} segment URLs with regex")
|
| 214 |
+
|
| 215 |
+
codecs_match = re.search(r'codecs="([^"]+)"', manifest_str)
|
| 216 |
+
if codecs_match:
|
| 217 |
+
codec = codecs_match.group(1).upper()
|
| 218 |
+
codecs_detected = codec
|
| 219 |
+
if 'MP4A' in codec or 'AAC' in codec:
|
| 220 |
+
audio_format = "m4a"
|
| 221 |
+
elif 'FLAC' in codec:
|
| 222 |
+
audio_format = "flac"
|
| 223 |
+
elif 'EC3' in codec or 'EAC3' in codec:
|
| 224 |
+
audio_format = "eac3"
|
| 225 |
+
|
| 226 |
+
mime_match = re.search(r'mimeType="([^"]+)"', manifest_str)
|
| 227 |
+
if mime_match:
|
| 228 |
+
mime = mime_match.group(1).lower()
|
| 229 |
+
if 'mp4' in mime or 'aac' in mime or 'm4a' in mime:
|
| 230 |
+
audio_format = "m4a"
|
| 231 |
+
elif 'flac' in mime:
|
| 232 |
+
audio_format = "flac"
|
| 233 |
+
|
| 234 |
+
base_match = re.search(r'<[^>]*BaseURL[^>]*>([^<]+)</', manifest_str)
|
| 235 |
+
if base_match:
|
| 236 |
+
base_url = base_match.group(1)
|
| 237 |
+
|
| 238 |
+
print(f" Final detected codec: {codecs_detected}, format: {audio_format}")
|
| 239 |
+
return base_url, segments, audio_format
|
| 240 |
|
| 241 |
def parse_bt_manifest(manifest_data):
|
| 242 |
"""Parse BT manifest (JSON format)"""
|
|
|
|
| 332 |
|
| 333 |
if is_lossless:
|
| 334 |
security_token = data.get('securityToken')
|
| 335 |
+
print(f" securityToken present: {bool(security_token)}, length: {len(security_token) if security_token else 0}")
|
| 336 |
key = None
|
| 337 |
nonce = None
|
| 338 |
|
|
|
|
| 342 |
|
| 343 |
download_dir = "./downloads"
|
| 344 |
os.makedirs(download_dir, exist_ok=True)
|
| 345 |
+
tmpdir = tempfile.mkdtemp(prefix="tidal-segments-")
|
| 346 |
+
temp_files = []
|
| 347 |
|
| 348 |
try:
|
| 349 |
print("π¦ Parsing DASH manifest...")
|
| 350 |
+
base_url, segments, audio_format = parse_dash_manifest(manifest_data)
|
| 351 |
+
print(f"β
Found {len(segments)} segments, format: {audio_format}")
|
| 352 |
|
| 353 |
print("β¬οΈ Downloading segments...")
|
| 354 |
+
|
| 355 |
for i, seg in enumerate(segments):
|
| 356 |
+
if seg.startswith('http://') or seg.startswith('https://'):
|
| 357 |
+
seg_url = seg
|
| 358 |
+
elif base_url:
|
| 359 |
+
seg_url = base_url + seg
|
| 360 |
+
else:
|
| 361 |
+
seg_url = seg
|
| 362 |
+
seg_path = os.path.join(tmpdir, f"segment_{i:04d}.mp4")
|
| 363 |
+
temp_files.append(seg_path)
|
| 364 |
|
| 365 |
max_retries = 3
|
| 366 |
success = False
|
|
|
|
| 383 |
|
| 384 |
print(f" Segment {i+1}/{len(segments)} downloaded")
|
| 385 |
|
| 386 |
+
print(f"π Merging {len(segments)} segments into {audio_format}...")
|
| 387 |
+
print(f" Temp files count: {len(temp_files)}")
|
| 388 |
+
print(f" Temp directory: {tmpdir}")
|
| 389 |
+
|
| 390 |
+
# Check first segment header
|
| 391 |
+
if temp_files:
|
| 392 |
+
with open(temp_files[0], 'rb') as f:
|
| 393 |
+
header = f.read(16).hex()
|
| 394 |
+
print(f" First segment header (hex): {header}")
|
| 395 |
+
if header.startswith('0000001866747970'):
|
| 396 |
+
print(" β
First segment is MP4 (ftyp header)")
|
| 397 |
+
elif header.startswith('0000010c6d6f6f66'):
|
| 398 |
+
print(" β οΈ First segment is fragmented MP4 (moof header)")
|
| 399 |
+
else:
|
| 400 |
+
print(f" β Unknown header: {header[:20]}...")
|
| 401 |
+
|
| 402 |
+
output_filename = f"{artist} - {title}.{audio_format}"
|
| 403 |
output_path = os.path.join(download_dir, output_filename)
|
| 404 |
+
print(f" Output path: {output_path}")
|
| 405 |
|
| 406 |
+
if audio_format == "flac":
|
| 407 |
+
merged_path = os.path.join(tmpdir, "merged.mp4")
|
| 408 |
+
print(f" Merged path: {merged_path}")
|
| 409 |
+
|
| 410 |
+
with open(merged_path, 'wb') as out:
|
| 411 |
+
print(" Starting merge...")
|
| 412 |
+
for idx, temp_file in enumerate(temp_files):
|
| 413 |
+
with open(temp_file, 'rb') as f:
|
| 414 |
+
out.write(f.read())
|
| 415 |
+
print(" Merge complete")
|
| 416 |
+
|
| 417 |
+
print(" Starting FFmpeg conversion...")
|
| 418 |
+
try:
|
| 419 |
+
import subprocess
|
| 420 |
|
| 421 |
+
# First check if the merged file is valid with ffprobe
|
| 422 |
+
probe_result = subprocess.run([
|
| 423 |
+
'ffprobe', '-v', 'error', '-show_streams', '-of', 'json', merged_path
|
| 424 |
+
], capture_output=True, text=True, timeout=30)
|
| 425 |
+
print(f"ffprobe return code: {probe_result.returncode}")
|
| 426 |
+
if probe_result.returncode == 0:
|
| 427 |
+
print(f"β
Merged file is valid MP4")
|
| 428 |
+
print(f"ffprobe output: {probe_result.stdout[:500]}")
|
| 429 |
else:
|
| 430 |
+
print(f"β Merged file analysis failed: {probe_result.stderr}")
|
|
|
|
|
|
|
| 431 |
|
| 432 |
+
# Use stream copy mode which is what OrpheusDL does
|
| 433 |
+
result = subprocess.run([
|
| 434 |
+
'ffmpeg', '-y', '-i', merged_path,
|
| 435 |
+
'-c', 'copy',
|
| 436 |
+
'-loglevel', 'error',
|
| 437 |
+
output_path
|
| 438 |
+
], capture_output=True, text=True, timeout=120)
|
| 439 |
+
|
| 440 |
+
print(f"FFmpeg return code: {result.returncode}")
|
| 441 |
+
print(f"FFmpeg stdout: {result.stdout[:2000] if result.stdout else 'None'}")
|
| 442 |
+
print(f"FFmpeg stderr: {result.stderr[:2000] if result.stderr else 'None'}")
|
| 443 |
+
|
| 444 |
+
if result.returncode != 0:
|
| 445 |
+
print(f"β οΈ FFmpeg conversion failed with code {result.returncode}: {result.stderr}")
|
| 446 |
+
raise HTTPException(status_code=500, detail=f"FFmpeg conversion failed: {result.stderr}")
|
| 447 |
+
|
| 448 |
+
print(f"β
Converted to FLAC: {output_path}")
|
| 449 |
+
except FileNotFoundError:
|
| 450 |
+
print("β οΈ FFmpeg not found, using raw merge")
|
| 451 |
+
with open(output_path, 'wb') as out:
|
| 452 |
+
for temp_file in temp_files:
|
| 453 |
+
with open(temp_file, 'rb') as f:
|
| 454 |
+
out.write(f.read())
|
| 455 |
+
except subprocess.TimeoutExpired:
|
| 456 |
+
print("β οΈ FFmpeg timeout")
|
| 457 |
+
raise HTTPException(status_code=500, detail="FFmpeg timeout")
|
| 458 |
+
except Exception as e:
|
| 459 |
+
print(f"β οΈ Exception during FFmpeg: {e}")
|
| 460 |
+
raise
|
| 461 |
+
else:
|
| 462 |
+
with open(output_path, 'wb') as out:
|
| 463 |
+
for temp_file in temp_files:
|
| 464 |
+
with open(temp_file, 'rb') as f:
|
| 465 |
+
out.write(f.read())
|
| 466 |
|
| 467 |
print(f"β
Download complete: {output_path}")
|
| 468 |
|
| 469 |
+
print(" Cleaning up temp files...")
|
| 470 |
+
import shutil
|
| 471 |
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
| 472 |
+
print(" Cleanup complete")
|
| 473 |
+
|
| 474 |
file_size = os.path.getsize(output_path)
|
| 475 |
|
| 476 |
return {
|
|
|
|
| 486 |
"isrc": isrc,
|
| 487 |
"quality": audio_quality,
|
| 488 |
"fileSize": file_size,
|
| 489 |
+
"format": audio_format
|
| 490 |
},
|
| 491 |
"downloadUrl": f"/download_file/{output_filename}",
|
| 492 |
"message": "File downloaded and ready for download"
|