From 8d41f014661164ba51a24ea07be94e82ca5f1fa9 Mon Sep 17 00:00:00 2001 From: Will Twomey Date: Sun, 3 May 2026 18:31:47 -0500 Subject: [PATCH] Fix JSON parse error on X multi-video posts X/Twitter posts with multiple videos cause yt-dlp -j to emit one JSON object per line, which broke json.loads(result.stdout) with "Extra data: line 2 column 1". Parse stdout line-by-line and, when multiple entries are returned, surface them as separate cards in the UI so each video can be previewed and downloaded individually. Downloads target a single video via --playlist-items. --- app.py | 77 +++++++++++++++++++++++++++----------------- templates/index.html | 49 ++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 37 deletions(-) diff --git a/app.py b/app.py index 703f435..5fecf69 100644 --- a/app.py +++ b/app.py @@ -13,11 +13,15 @@ jobs = {} -def run_download(job_id, url, format_choice, format_id): +def run_download(job_id, url, format_choice, format_id, playlist_index=None): job = jobs[job_id] out_template = os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s") - cmd = ["yt-dlp", "--no-playlist", "-o", out_template] + cmd = ["yt-dlp", "-o", out_template] + if playlist_index: + cmd += ["--playlist-items", str(playlist_index)] + else: + cmd += ["--no-playlist"] if format_choice == "audio": cmd += ["-x", "--audio-format", "mp3"] @@ -91,33 +95,45 @@ def get_info(): if result.returncode != 0: return jsonify({"error": result.stderr.strip().split("\n")[-1]}), 400 - info = json.loads(result.stdout) - - # Build quality options — keep best format per resolution - best_by_height = {} - for f in info.get("formats", []): - height = f.get("height") - if height and f.get("vcodec", "none") != "none": - tbr = f.get("tbr") or 0 - if height not in best_by_height or tbr > (best_by_height[height].get("tbr") or 0): - best_by_height[height] = f - - formats = [] - for height, f in best_by_height.items(): - formats.append({ - "id": f["format_id"], - "label": f"{height}p", - "height": height, - }) - formats.sort(key=lambda x: x["height"], reverse=True) - - return jsonify({ - "title": info.get("title", ""), - "thumbnail": info.get("thumbnail", ""), - "duration": info.get("duration"), - "uploader": info.get("uploader", ""), - "formats": formats, - }) + # yt-dlp outputs one JSON object per line for multi-video posts + lines = [l for l in result.stdout.strip().splitlines() if l.strip()] + entries = [json.loads(line) for line in lines] + + def extract_info(info): + best_by_height = {} + for f in info.get("formats", []): + height = f.get("height") + if height and f.get("vcodec", "none") != "none": + tbr = f.get("tbr") or 0 + if height not in best_by_height or tbr > (best_by_height[height].get("tbr") or 0): + best_by_height[height] = f + + formats = [] + for height, f in best_by_height.items(): + formats.append({ + "id": f["format_id"], + "label": f"{height}p", + "height": height, + }) + formats.sort(key=lambda x: x["height"], reverse=True) + + return { + "title": info.get("title", ""), + "thumbnail": info.get("thumbnail", ""), + "duration": info.get("duration"), + "uploader": info.get("uploader", ""), + "formats": formats, + } + + if len(entries) == 1: + return jsonify(extract_info(entries[0])) + else: + videos = [] + for i, e in enumerate(entries): + v = extract_info(e) + v["playlist_index"] = i + 1 + videos.append(v) + return jsonify({"multiple": True, "videos": videos, "url": url}) except subprocess.TimeoutExpired: return jsonify({"error": "Timed out fetching video info"}), 400 except Exception as e: @@ -131,6 +147,7 @@ def start_download(): format_choice = data.get("format", "video") format_id = data.get("format_id") title = data.get("title", "") + playlist_index = data.get("playlist_index") if not url: return jsonify({"error": "No URL provided"}), 400 @@ -138,7 +155,7 @@ def start_download(): job_id = uuid.uuid4().hex[:10] jobs[job_id] = {"status": "downloading", "url": url, "title": title} - thread = threading.Thread(target=run_download, args=(job_id, url, format_choice, format_id)) + thread = threading.Thread(target=run_download, args=(job_id, url, format_choice, format_id, playlist_index)) thread.daemon = True thread.start() diff --git a/templates/index.html b/templates/index.html index 0bae3d3..8fc8194 100644 --- a/templates/index.html +++ b/templates/index.html @@ -477,6 +477,39 @@

ReClip

const data = await res.json(); if (data.error) { cardData[idx] = { ...cardData[idx], status: 'info-error', error: data.error }; + } else if (data.multiple) { + // Multi-video post: replace loading card with first video, add rest + const videos = data.videos; + const postUrl = data.url || url; + cardData[idx] = { + ...cardData[idx], + status: 'ready', + title: videos[0].title || '', + thumbnail: videos[0].thumbnail || '', + duration: videos[0].duration, + uploader: videos[0].uploader || '', + formats: videos[0].formats || [], + selectedFormatId: videos[0].formats?.[0]?.id || null, + downloadUrl: postUrl, + playlistIndex: videos[0].playlist_index, + }; + renderCard(idx); + for (let v = 1; v < videos.length; v++) { + const extraIdx = cardData.length; + cardData.push({ + url: postUrl, + status: 'ready', + title: videos[v].title || '', + thumbnail: videos[v].thumbnail || '', + duration: videos[v].duration, + uploader: videos[v].uploader || '', + formats: videos[v].formats || [], + selectedFormatId: videos[v].formats?.[0]?.id || null, + downloadUrl: postUrl, + playlistIndex: videos[v].playlist_index, + }); + renderCard(extraIdx); + } } else { cardData[idx] = { ...cardData[idx], @@ -581,7 +614,7 @@

ReClip

${thumbHtml}
${esc(c.title || 'Untitled')}
-
${esc(c.uploader)}${c.duration ? ' · ' + fmtDur(c.duration) : ''}
+
${esc(c.uploader)}${c.duration ? ' · ' + fmtDur(c.duration) : ''}${c.playlistIndex ? ' · Video ' + c.playlistIndex : ''}
${actionHtml}
`; @@ -610,15 +643,17 @@

ReClip

renderCard(idx); try { + const payload = { + url: c.downloadUrl || c.url, + format: currentFormat, + format_id: c.selectedFormatId, + title: c.title || '', + }; + if (c.playlistIndex) payload.playlist_index = c.playlistIndex; const res = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: c.url, - format: currentFormat, - format_id: c.selectedFormatId, - title: c.title || '', - }), + body: JSON.stringify(payload), }); const data = await res.json(); if (data.error) {