Skip to content

Commit f1917d6

Browse files
committed
Finish minimal working state
1 parent e595330 commit f1917d6

File tree

8 files changed

+308
-11
lines changed

8 files changed

+308
-11
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.env
2-
app/__pycache__/
2+
__pycache__/
33
.venv/
4+
test.py

app/main.py

+39-10
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,54 @@
11
import uvicorn
2-
from fastapi import FastAPI, HTTPException, Depends
2+
from fastapi import FastAPI, Request, HTTPException, Depends
33
from sqlalchemy.orm import Session
44
import crud, models, schemas
5+
from pydantic import BaseModel
56
from database import engine, get_db, SessionLocal
7+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
8+
from fastapi.staticfiles import StaticFiles
9+
from fastapi.templating import Jinja2Templates
10+
import yt_dlp
11+
from modules.downloader import download_audio, download_video, get_domain, sanitize_filename
612

713
app = FastAPI()
14+
templates = Jinja2Templates(directory="templates")
815

916
models.Base.metadata.create_all(bind=engine)
1017

18+
class VideoRequest(BaseModel):
19+
url: str
20+
21+
1122
@app.get("/")
12-
def read_root():
13-
return {"Hello": "World"}
23+
async def home(request: Request):
24+
return templates.TemplateResponse("index.html", {"request": request})
25+
1426

15-
@app.post("/users/", response_model=schemas.User)
16-
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
17-
return crud.create_user(db=db, user=user)
27+
@app.post("/download")
28+
async def download(request: VideoRequest):
29+
""" Download a video or audio based on the frontend request """
30+
url = request.url
31+
ydl_opts = {
32+
'quiet': True,
33+
'extract_flat': True,
34+
}
35+
36+
try:
37+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
38+
info = ydl.extract_info(url, download=False)
39+
if not info:
40+
raise HTTPException(status_code=400, detail="Could not retrieve video info")
41+
42+
video_data = {
43+
"title": info.get("title"),
44+
"author": info.get("uploader"),
45+
"thumbnail": info.get("thumbnail"),
46+
"resolutions": sorted(set(f["format_note"] for f in info.get("formats", []) if "format_note" in f))
47+
}
48+
return video_data
49+
except Exception as e:
50+
raise HTTPException(status_code=500, detail=str(e))
1851

19-
@app.get("/users/", response_model=list[schemas.User])
20-
def read_users(skip:int=0, limit:int=100, db: Session = Depends(get_db)):
21-
users = crud.get_users(db, skip=skip, limit=limit)
22-
return users
2352

2453
@app.get("/users/{user_id}/",response_model=schemas.User)
2554
def get_user(user_id:int, db:Session=Depends(get_db)):

app/modules/downloader.py

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import yt_dlp
2+
import os
3+
import re
4+
import time
5+
import subprocess
6+
from PIL import Image
7+
8+
# Limit to 5 concurrent downloads
9+
10+
11+
def clear_downloads():
12+
"""Clears files in the downloads folder."""
13+
for file in os.listdir('downloads'):
14+
delete_file(f"downloads/{file}")
15+
16+
def crop_to_square(image_path, out_path):
17+
"""Crops an image to a square format."""
18+
with Image.open(image_path) as img:
19+
width, height = img.size
20+
new_size = min(width, height)
21+
left = (width - new_size) / 2
22+
top = (height - new_size) / 2
23+
right = (width + new_size) / 2
24+
bottom = (height + new_size) / 2
25+
img = img.crop((left, top, right, bottom))
26+
img.save(out_path)
27+
28+
def sanitize_filename(text: str):
29+
"""Sanitizes filenames by removing invalid characters."""
30+
return re.sub(r'[\\/:"*?<>|]+', '', text).replace(' ', '_')
31+
32+
def get_video_formats(url: str, domain: str):
33+
"""Retrieves available video resolutions."""
34+
ydl_opts = {'quiet': True}
35+
# if domain.startswith("you"):
36+
# ydl_opts['cookiefile'] = '/cookies/youtube.txt'
37+
# elif domain == 'instagram.com':
38+
# ydl_opts['cookiefile'] = '/cookies/insta.txt'
39+
40+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
41+
info_dict = ydl.extract_info(url, download=False)
42+
return [
43+
{
44+
'format_id': fmt['format_id'],
45+
'resolution': fmt.get('resolution', 'N/A'),
46+
'ext': fmt['ext']
47+
} for fmt in info_dict.get('formats', [])
48+
]
49+
50+
def get_domain(url: str):
51+
"""Extracts domain name from URL."""
52+
match = re.match(r'^(https?:\/\/)?(www\.)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\/.*)?$', url)
53+
return match.group(3) if match else None
54+
55+
def delete_file(f_path):
56+
"""Attempts to delete a file."""
57+
try:
58+
os.remove(f_path)
59+
except:
60+
time.sleep(5)
61+
62+
def download_audio(video_url, output_path, thumb):
63+
"""Downloads audio with metadata and thumbnail."""
64+
65+
try:
66+
ydl_opts = {
67+
'format': 'bestaudio/best',
68+
'postprocessors': [
69+
{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '320'},
70+
{'key': 'FFmpegMetadata'},
71+
{'key': 'EmbedThumbnail'}
72+
],
73+
'outtmpl': output_path[:-4],
74+
'writethumbnail': True,
75+
# 'cookiefile': 'cookies/youtube.txt'
76+
}
77+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
78+
ydl.download([video_url])
79+
# audio_thumb = f"{thumb[:-4]}_audio.jpg"
80+
# try:
81+
# crop_to_square(thumb, audio_thumb)
82+
# except:
83+
# pass
84+
# delete_file(audio_thumb)
85+
except Exception as e:
86+
print(f"Audio download failed: {e}")
87+
88+
def download_video(url, output_path="downloads/%(title)s.%(ext)s",):
89+
"""Downloads video with user-selected format."""
90+
91+
ydl_opts = {
92+
'format': 'bestvideo+bestaudio/best',
93+
'merge_output_format': 'mp4',
94+
'outtmpl': output_path
95+
}
96+
try:
97+
98+
# if domain == "instagram.com":
99+
# ydl_opts['cookiefile'] = 'cookies/insta.txt'
100+
# elif domain.startswith("youtu"):
101+
# ydl_opts['cookiefile'] = 'cookies/youtube.txt'
102+
103+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
104+
ydl.download([url])
105+
except Exception as e:
106+
print(f"Video download failed: {e}")
107+
108+
def download_image(url, output_path, domain):
109+
"""Downloads an image from Instagram, Twitter, or X."""
110+
111+
try:
112+
if domain in ["instagram.com", "twitter.com", "x.com"]:
113+
output_path = output_path.replace("mp4", "jpg")
114+
command = [
115+
"gallery-dl", "--config", "gallery-dl.conf",
116+
"--filename", output_path.replace("downloads/", ""),
117+
"--directory", "downloads/", url
118+
]
119+
subprocess.run(command)
120+
except Exception as e:
121+
print(f"Image download failed: {e}")
122+
delete_file(output_path)
123+

app/schemas.py

+6
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,11 @@ class User(UserBase):
1515
id : int
1616
created_at : datetime
1717

18+
class DownloadRequest(BaseModel):
19+
url: str
20+
title: str
21+
format_type: str
22+
resolution: str
23+
1824
class Config:
1925
from_attributes = True

public/favicon.ico

66.1 KB
Binary file not shown.

requirements.txt

+5
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ anyio==4.8.0
33
certifi==2025.1.31
44
click==8.1.8
55
dnspython==2.7.0
6+
dotenv==0.9.9
67
email_validator==2.2.0
78
fastapi==0.115.10
89
fastapi-cli==0.0.7
10+
greenlet==3.1.1
911
h11==0.14.0
1012
httpcore==1.0.7
1113
httptools==0.6.4
@@ -15,6 +17,7 @@ Jinja2==3.1.5
1517
markdown-it-py==3.0.0
1618
MarkupSafe==3.0.2
1719
mdurl==0.1.2
20+
psycopg2-binary==2.9.10
1821
pydantic==2.10.6
1922
pydantic_core==2.27.2
2023
Pygments==2.19.1
@@ -25,10 +28,12 @@ rich==13.9.4
2528
rich-toolkit==0.13.2
2629
shellingham==1.5.4
2730
sniffio==1.3.1
31+
SQLAlchemy==2.0.38
2832
starlette==0.46.0
2933
typer==0.15.2
3034
typing_extensions==4.12.2
3135
uvicorn==0.34.0
3236
uvloop==0.21.0
3337
watchfiles==1.0.4
3438
websockets==15.0
39+
yt-dlp==2025.2.19

templates/index.html

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<style>
5+
body {
6+
font-family: Arial, sans-serif;
7+
margin: 20px;
8+
background: #070514;
9+
color: #abb2b0;
10+
}
11+
</style>
12+
<meta charset="UTF-8">
13+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
14+
<title>YouTube Downloader</title>
15+
</head>
16+
<body>
17+
<h1>Flow-Fetch Universal downloader</h1>
18+
<p>Download videos from YouTube, Facebook, Instagram, Twitter, Vimeo, Dailymotion, and many more.</p>
19+
<p>Just paste the URL and download the video in any format you want.</p>
20+
<p>It's free, fast, and easy to use.</p>
21+
<input type="text" id="youtube-url" placeholder="Paste YouTube link here">
22+
<button onclick="fetchVideoInfo()">Check Video</button>
23+
24+
<div id="video-info" style="display: none;">
25+
<h3 id="video-title"></h3>
26+
<p id="video-author"></p>
27+
<img id="video-thumbnail" src="" alt="Thumbnail">
28+
29+
<h4>Choose Resolution</h4>
30+
<select id="resolution-select"></select>
31+
<button onclick="downloadVideo()">Download</button>
32+
</div>
33+
34+
<script>
35+
let videoData = {}; // Store video details
36+
37+
async function fetchVideoInfo() {
38+
const url = document.getElementById("youtube-url").value;
39+
const videoID = extractYouTubeID(url);
40+
if (!videoID) {
41+
alert("Invalid YouTube URL");
42+
return;
43+
}
44+
45+
try {
46+
// Get video info using oEmbed
47+
const oEmbedResponse = await fetch(`https://www.youtube.com/oembed?url=${url}&format=json`);
48+
if (!oEmbedResponse.ok) throw new Error("Invalid YouTube URL");
49+
const oEmbedData = await oEmbedResponse.json();
50+
51+
// Generate potential resolutions
52+
const resolutions = {
53+
"144p": `https://img.youtube.com/vi/${videoID}/default.jpg`,
54+
"240p": `https://img.youtube.com/vi/${videoID}/mqdefault.jpg`,
55+
"360p": `https://img.youtube.com/vi/${videoID}/hqdefault.jpg`,
56+
"480p": `https://img.youtube.com/vi/${videoID}/sddefault.jpg`,
57+
"720p": `https://img.youtube.com/vi/${videoID}/maxresdefault.jpg`
58+
};
59+
60+
// Check available thumbnails (to determine available resolutions)
61+
const availableResolutions = {};
62+
for (const [res, imgUrl] of Object.entries(resolutions)) {
63+
const response = await fetch(imgUrl, { method: "HEAD" });
64+
if (response.ok) availableResolutions[res] = imgUrl;
65+
}
66+
67+
// Store video data
68+
videoData = {
69+
url: url,
70+
title: oEmbedData.title,
71+
author: oEmbedData.author_name,
72+
thumbnail: Object.values(availableResolutions).pop(), // Highest available thumbnail
73+
resolutions: Object.keys(availableResolutions)
74+
};
75+
76+
// Update UI
77+
document.getElementById("video-title").innerText = videoData.title;
78+
document.getElementById("video-author").innerText = `By: ${videoData.author}`;
79+
document.getElementById("video-thumbnail").src = videoData.thumbnail;
80+
81+
// Populate resolution options
82+
const resolutionSelect = document.getElementById("resolution-select");
83+
resolutionSelect.innerHTML = "";
84+
videoData.resolutions.forEach(res => {
85+
const option = document.createElement("option");
86+
option.value = res;
87+
option.innerText = res;
88+
resolutionSelect.appendChild(option);
89+
});
90+
91+
document.getElementById("video-info").style.display = "block";
92+
93+
} catch (error) {
94+
alert("Error fetching video info: " + error.message);
95+
}
96+
}
97+
98+
function extractYouTubeID(url) {
99+
const match = url.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/);
100+
return match ? match[1] : null;
101+
}
102+
103+
async function downloadVideo() {
104+
const selectedResolution = document.getElementById("resolution-select").value;
105+
const formatType = "video";
106+
107+
// Send request to FastAPI backend
108+
const response = await fetch("/download", {
109+
method: "GET",
110+
headers: { "Content-Type": "application/json" },
111+
body: JSON.stringify({
112+
url: videoData.url,
113+
resolution: selectedResolution,
114+
format_type: formatType,
115+
})
116+
});
117+
118+
if (response.ok) {
119+
const blob = await response.blob();
120+
const a = document.createElement("a");
121+
a.href = URL.createObjectURL(blob);
122+
a.download = videoData.title.replace(/[^a-z0-9]/gi, '_') + ".mp4";
123+
document.body.appendChild(a);
124+
a.click();
125+
document.body.removeChild(a);
126+
} else {
127+
alert("Download failed!");
128+
console.error(response);
129+
}
130+
}
131+
</script>
132+
</body>
133+
</html>

templates/results.html

Whitespace-only changes.

0 commit comments

Comments
 (0)