Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 47 additions & 30 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand All @@ -131,14 +147,15 @@ 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

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()

Expand Down
49 changes: 42 additions & 7 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,39 @@ <h1>Re<em>Clip</em></h1>
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],
Expand Down Expand Up @@ -581,7 +614,7 @@ <h1>Re<em>Clip</em></h1>
<div class="card-thumb">${thumbHtml}</div>
<div class="card-body">
<div class="card-title">${esc(c.title || 'Untitled')}</div>
<div class="card-meta">${esc(c.uploader)}${c.duration ? ' · ' + fmtDur(c.duration) : ''}</div>
<div class="card-meta">${esc(c.uploader)}${c.duration ? ' · ' + fmtDur(c.duration) : ''}${c.playlistIndex ? ' · Video ' + c.playlistIndex : ''}</div>
<div class="card-actions">${actionHtml}</div>
</div>
`;
Expand Down Expand Up @@ -610,15 +643,17 @@ <h1>Re<em>Clip</em></h1>
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) {
Expand Down