re-organized backend

This commit is contained in:
2026-01-19 21:42:34 +00:00
parent 6b08f55983
commit 8ecadcf2e5
7 changed files with 581 additions and 221 deletions

View File

@@ -0,0 +1,234 @@
from fastapi import FastAPI, HTTPException, Request, Response
#from fastapi import Body, Cookie, File, Form, Header, Path, Query
from contextlib import asynccontextmanager
from pydantic import BaseModel, ValidationError
from typing import Optional
import uvicorn
import aiomysql
import os
import bcrypt
import asyncio
import aiosqlite
import subprocess
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
class db:
mode = os.environ.get("DB_MODE", "sqlite")
# For SQLite usage
file = os.environ.get("SQLITE_FILE","./database.db")
# For MySQL usage
pool = None
host = os.environ.get("MYSQL_HOST","127.0.0.1")
user = os.environ.get("MYSQL_USER","yt2mp3")
db_name = os.environ.get("MYSQL_DB","yt2mp3")
pwd = os.environ.get("MYSQL_PASSWORD","")
@asynccontextmanager
async def lifespan(app: FastAPI):
if db.mode == "mysql":
db.pool = await aiomysql.create_pool(host=db.host,user=db.user,password=db.pwd,db=db.db_name, minsize=1, maxsize=10)
try:
yield
finally:
db.pool.close()
await db.pool.wait_closed()
else:
try:
yield
finally: pass # no action required now
app = FastAPI(lifespan=lifespan)
#
# Fetch user info.
# Returns a JSON of the user or None if nothing exists (like an incorrect email)
#
async def fetch_user_info(arg: str, type: str):
if db.mode == "mysql":
async with db.pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("SELECT * FROM users WHERE %s = %s", (type, arg))
return await cur.fetchone()
elif db.mode == "sqlite":
async with aiosqlite.connect(db.file) as conn:
async with conn.execute("SELECT * FROM users WHERE ? = ?", (type, arg)) as cursor:
return await cursor.fetchone()
#
# Downloads the video and extracts the audio
#
def download_audio(video_url: str, output_folder: str, kbps:int) -> str:
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': os.path.join(output_folder, '%(title)s.%(ext)s'),
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': str(kbps),
}, {
'key': 'FFmpegMetadata',
'add_metadata': True,
}],
'quiet': True,
'noplaylist': True
}
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
filename = ydl.prepare_filename(info)
return os.path.splitext(filename)[0] + ".mp3" # path to final MP3
#
# Uses FFmpeg to edit the artist and album if specified
#
def set_metadata_and_rename(mp3_file: str, name: str, artist: str = None, album: str = None) -> str:
new_file = os.path.abspath(os.path.join(os.path.dirname(mp3_file), f"{name}.mp3"))
cmd = ["ffmpeg", "-y", "-i", mp3_file]
# Only add metadata if provided
cmd += ["-metadata", f"title={name}"]
if artist:
cmd += ["-metadata", f"artist={artist}"]
if album:
cmd += ["-metadata", f"album={album}"]
cmd += [new_file]
subprocess.run(cmd, check=True)
os.remove(mp3_file) # clean original
return new_file
#
# The download folder.
# It goes:
# FOLDER/<user_id>/<filename>
#
DOWNLOAD_FOLDER = os.environ.get("DOWNLOAD_FOLDER","/tmp/downloads") # Where the converted files will be saved
if not os.path.exists(DOWNLOAD_FOLDER):
os.makedirs(DOWNLOAD_FOLDER)
# Throw error if the env is incorrect
try:
AUTODELETE_DELAY_SECS = int(os.environ.get("FILE_AUTODELETE_DELAY_SECS", 0))
except ValueError:
raise EnvironmentError("You must set AUTODELETE_DELAY_SECS to a valid number (int) of seconds! To disable auto delete (not recommended), set it to 0")
#
# Asyncioway of scheduling a file delete with asyncio.sleep() and os.unlink()
#
async def delay_delete_file(file:str):
if (AUTODELETE_DELAY_SECS > 0):
await asyncio.sleep(AUTODELETE_DELAY_SECS)
if os.path.exists(file): os.unlink(file)
@app.post("/convert")
async def create_conversion(req: Request):
"""Converts a YouTube video to MP3."""
body = await req.json()
print(body)
video_url = body.get("video_url")
user_id = body.get("user_id")
quality = body.get("kbps")
if (video_url is None): raise HTTPException(400, "Please provide an URL on body")
if (body.get("user_id") is None): raise HTTPException(401, "No User ID")
# User folder is DOWNLOAD_FOLDER/<userid>
USER_FOLDER = os.path.join(DOWNLOAD_FOLDER, str(user_id))
if not os.path.exists(USER_FOLDER): os.mkdir(USER_FOLDER)
try:
file = await asyncio.to_thread(download_audio, video_url, USER_FOLDER, quality)
except DownloadError as e:
raise HTTPException(500, "Error while downloading " + e.msg)
final_file = await asyncio.to_thread(set_metadata_and_rename, file, body.get("song"), body.get("artist"), body.get("album"))
file_name = final_file.split("/")[-1]
print("File served: " + final_file)
asyncio.create_task(delay_delete_file(final_file))
return { "file" : file_name }
@app.post("/register")
async def create_user(req: Request):
body = await req.json()
body["password"] = bcrypt.hashpw(body["password"].encode(), bcrypt.gensalt()).decode()
if db.mode == "mysql":
async with db.pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("INSERT INTO users (`first_name`,`last_name`,`email`,`password`) VALUES (%s,%s,%s,%s)", (body.first_name, body.last_name, body.email, body.password))
await conn.commit()
elif db.mode == "sqlite":
async with aiosqlite.connect(db.file) as conn:
await conn.execute("INSERT INTO users (`first_name`,`last_name`,`email`,`password`) VALUES (?, ?, ?, ?)", (body.first_name, body.last_name, body.email, body.password))
await conn.commit()
@app.post("/login")
async def login_endpoint(req: Request):
body = await req.json()
#print(body)
try:
user_data = await fetch_user_info(body["email"], "email")
if (user_data is None):
print("User doesn't exist")
raise HTTPException(status_code=401, detail="User doesn't exist")
else:
pwd = user_data.get("password")
if (bcrypt.checkpw(body["password"].encode(), user_data.get("password").encode())):
return user_data
else:
print("Wrong password")
raise HTTPException(status_code=401, detail="Wrong password")
except KeyError:
raise HTTPException(status_code=400)
#except ValidationError:
# raise HTTPException(status_code=400)
@app.get("/me")
async def me_get_endpoint(req: Request):
"""
Retrieves user profile data.
Trusts Nuxt's backend to allow it or not.
"""
args = dict(req.query_params)
me = None
try:
# Prefer id over e-mail
if "id" in args.keys(): me = fetch_user_info(args["id"], "id")
elif "email" in args.keys(): me = fetch_user_info(args["email"], "email")
else: raise HTTPException(400, "Provide either email or user id")
#print(args)
if (me is None):
raise HTTPException(404, "User doesn't exist")
else: return me
except KeyError:
raise HTTPException(400, "Please provide email")
if __name__ == "__main__":
listen_host = str(os.environ.get("HOST", "127.0.0.1"))
listen_port = int(os.environ.get("PORT", 3000))
uvicorn.run(app,host=listen_host,port=listen_port)