40 Commits

Author SHA1 Message Date
eb35a99c3e Added chmod 2026-01-30 11:02:01 +00:00
61233b3611 New docker entrypoint 2026-01-30 10:48:40 +00:00
4ec441e688 Updated yt dlp version 2026-01-30 00:48:12 +00:00
263ec699ad changed render modes 2026-01-29 21:42:56 +00:00
6a1eef0f76 enabled color mode transition 2026-01-29 21:42:22 +00:00
ea27e4e8d5 searchbar changed 2026-01-22 12:52:47 +00:00
50ba23fdb4 small correction :/ 2026-01-22 00:03:31 +00:00
e66c7dfb89 redirect fixed 2026-01-21 22:24:42 +00:00
c10f71b2db added titles on pages 2026-01-21 21:27:41 +00:00
1ecab34d79 deleted favicon 2026-01-21 21:26:52 +00:00
3d2bcc8ea6 new based path 2026-01-21 20:48:58 +00:00
4338f347c5 based path correction 2026-01-21 20:48:45 +00:00
3eeaa797ad includes status message on error 2026-01-21 18:35:53 +00:00
170d34c08c fetch changed 2026-01-21 18:34:32 +00:00
6edbf55756 256 kbps default 2026-01-21 18:33:54 +00:00
1ac08fd656 mdw deleted 2026-01-21 18:33:34 +00:00
3892c5ea4e fetch corrected 2026-01-21 18:33:26 +00:00
4ed1fb9401 uses toaster by default 2026-01-21 18:28:42 +00:00
501580b2e0 changed functions for nuxt-friendly version 2026-01-21 18:28:26 +00:00
048bcd1b5a added url join 2026-01-21 18:26:44 +00:00
2577dc46ee urls corrected on fetch 2026-01-20 22:25:00 +00:00
c9189c4ef1 removed the admin port 2026-01-20 22:24:40 +00:00
be678c1cf0 corrected env vars for being dynamic 2026-01-20 21:07:10 +00:00
c020ea968c toasts and body parse fixed 2026-01-20 19:56:20 +00:00
8486b95a45 changed fastapi path 2026-01-20 18:02:35 +00:00
b3e14c1e02 changed mysql cred 2026-01-20 18:02:27 +00:00
a9a6de8738 added toaster messages 2026-01-20 17:41:06 +00:00
2c5c34eeac added toaster 2026-01-20 17:40:57 +00:00
45448862c7 status -> statusCode 2026-01-20 17:40:49 +00:00
9670293b51 yeahh buddy 2026-01-20 17:14:21 +00:00
16e5d79460 no log now 2026-01-20 17:14:07 +00:00
caf80f24ef backend zod verification 2026-01-20 17:13:56 +00:00
c0a1a0b944 idk i think it works 2026-01-20 17:13:41 +00:00
5d39a150e7 forms complete 2026-01-20 17:13:06 +00:00
f7623483f8 remember added 2026-01-20 17:12:52 +00:00
fb654afd11 reload better 2026-01-20 17:12:42 +00:00
3663061e02 redirect works 2026-01-20 17:12:32 +00:00
accf35e364 login works backend 2026-01-20 01:33:55 +00:00
797a696aff updated for internal deploy 2026-01-19 21:43:08 +00:00
8ecadcf2e5 re-organized backend 2026-01-19 21:42:34 +00:00
35 changed files with 1196 additions and 293 deletions

View File

@@ -9,7 +9,9 @@ RUN pip install --no-cache-dir -r requirements_prod.txt
#RUN apt update && apt install ffmpeg -y
#COPY server/py/fastapi_server/ ./
COPY ./server/py/fastapi_server/*.py ./
COPY --chmod=755 ./server/py/fastapi_server/*.py ./
# Copy the entrypoint to root
COPY --chmod=755 ./server/py/fastapi_server/entrypoint.sh /
ENV PORT=8000
ENV HOST=0.0.0.0
@@ -18,6 +20,10 @@ ENV DOWNLOAD_FOLDER=/tmp/downloads
# Ammount (in secs) before file autodeletes
ENV FILE_AUTODELETE_DELAY_SECS=300
ENV ROOT_PATH=/
VOLUME ["/tmp/downloads"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python3", "main.py"]

View File

@@ -23,9 +23,9 @@ ENV HOST=0.0.0.0
EXPOSE 3000
ENV DOWNLOAD_FOLDER=/tmp/downloads
ENV NUXT_DOWNLOAD_FOLDER=/tmp/downloads
VOLUME ["/tmp/downloads"]
ENV FASTAPI_URL=http://127.0.0.1:8000
ENV NUXT_FASTAPI_URL=http://127.0.0.1:8000
CMD ["node", "/app/server/index.mjs"]

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ui_colors_dropdown } from '~/utils/set_colors';
const loggedIn = ref(false);
const logout_prompt = ref(false);
@@ -27,14 +26,11 @@ const props = defineProps({
})
function logout() {
return fetch("/logout").then(
(response) => {
if (response.ok) {
window.location.reload();
}
}
)
return $fetch("/logout").then((r) => {
reloadNuxtApp({ path: useBasedPath("/") })
});
}
</script>

View File

@@ -0,0 +1,6 @@
import urlJoin from "url-join"
export default function (route: string) {
const base_url = useRuntimeConfig().app.baseURL
return urlJoin(base_url, route)
}

7
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
</script>
<template>
<slot />
<UToaster />
</template>

View File

@@ -20,4 +20,5 @@
<MainHeader class="mb-0" :settings_link="false" :logged_in="loggedIn"/>
<UNavigationMenu :items="settings_submenu" class="justify-center mt-2 mb-2"/>
<slot />
<UToaster />
</template>

View File

@@ -1,12 +1,24 @@
<script setup lang="ts">
import * as z from 'zod'
import urlJoin from 'url-join';
import type { FormSubmitEvent } from '@nuxt/ui'
const { loggedIn } = useUserSession()
useHead({
title: "Youtube 2 MP3"
})
definePageMeta({
prerender: true // HTML prerender (faster)
})
const { loggedIn } = useUserSession()
const conf = useRuntimeConfig()
const toast = useToast()
const video = ref(0);
const url = ref("");
const in_url = ref("");
const dwn_link = ref("");
const file_dwnl = ref("");
@@ -34,35 +46,39 @@
async function start_conversion(event: FormSubmitEvent<musicParamsSchema>) {
return fetch("/convert", {
return $fetch("/convert", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
body: {
url: url.value,
song: music_params.song,
artist: music_params.artist,
album: music_params.album
})
}).then(async r => {
//console.log(r.status)
if(r.status == 401)
window.location.href = "/login"
if(r.ok) {
const data = await r.json(); // resolves to { file: "my_audio.mp3" }
const file = data.file;
file_dwnl.value = `/download/${file}`;
const a = document.createElement("a");
a.href = file_dwnl.value;
a.download = file; // sets suggested filename
document.body.appendChild(a); // must append first
a.click(); // triggers download
a.remove();
}
})
}).then(r => { // r resolves to { file: "my_audio.mp3" }
const file = r.file; // in this case r["file"] --> "my_audio.mp3"
const a = document.createElement("a");
a.href = useBasedPath(`/download/${file}`)
a.download = file; // sets suggested filename
file_dwnl.value = a.href; // this ref activates the Download button
document.body.appendChild(a); // must append first
a.click(); // triggers download
a.remove();
}).catch(r => {
// response is NOT ok (401, 500, etc...)
const res = r.data;
console.log(r.data)
if(res.statusCode == 401) {
navigateTo(useBasedPath("/login"),{external: true})
}
else toast.add({
title: "Error",
description: "Returned error. Check your YT link, if this happens too often, try again in a couple of minutes",
color: "error"
})
}
})
}
</script>
@@ -75,10 +91,10 @@
<UInput
class="self-center geist-mono mt-5 mb-5 max-sm:w-[92%] max-md:w-4/5 max-lg:w-4/5 lg:w-2/3 max-w-300"
:ui="{ base: 'rounded-2xl h-15 text-toned', trailing: 'p-0' }"
placeholder="Search for a YouTube video..."
placeholder="Paste a YouTube URL here..."
v-model="in_url" >
<template #trailing>
<UButton class="self-end rounded-r-2xl h-15 px-3 hover:cursor-pointer" icon="lucide:search" @click="get_yt_vid" />
<UButton class="self-end rounded-r-2xl h-15 px-3 hover:cursor-pointer" icon="lucide:arrow-right" @click="get_yt_vid" />
</template>
</UInput>
@@ -103,7 +119,7 @@
<div class="text-center">
<UButton class="mt-4 rounded-2xl px-9 w-2/3 py-3 justify-center text-center " loading-auto type="submit">Convert to MP3</UButton>
</div>
<div class="text-center">
<div class="text-center" v-if="dwn_link != ''">
<UButton class="mt-4 rounded-2xl px-9 w-2/3 py-3 justify-center text-center" icon="lucide:cloud-download" color="success" >Download</UButton>
</div>
</UForm>

View File

@@ -1,10 +1,24 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent, AuthFormField } from '@nuxt/ui'
import urlJoin from 'url-join'
useHead({
title: "Login"
})
definePageMeta({
ssr: false // CSR
})
const toast = useToast()
const config = useRuntimeConfig()
// animation & status refs
const wrong_login = ref(false)
const login_pending = ref(false)
// variable of timeout type
let wrong_login_timeout : ReturnType<typeof setTimeout>
const fields: AuthFormField[] = [{
name: 'email',
@@ -29,28 +43,33 @@ const fields: AuthFormField[] = [{
const schema = z.object({
email: z.email('Invalid email'),
password: z.string('Password is required').min(8, 'Must be at least 8 characters')
password: z.string('Password is required').min(8, 'Must be at least 8 characters'),
remember: z.boolean().optional()
})
type Schema = z.output<typeof schema>
function onSubmit(payload: FormSubmitEvent<Schema>) {
return fetch("/login", {
async function onSubmit(payload: FormSubmitEvent<Schema>) {
clearTimeout(wrong_login_timeout); // cancel the timeout
wrong_login.value = false // hide it if it was
login_pending.value = true // mark it as pending
await $fetch("/login", {
method: "POST",
headers: {
"Content-Type" : "application/json"
},
body: JSON.stringify({
body: {
email: payload.data.email,
password: payload.data.password
})
}).then((response) => {
if (response.ok)
window.location.href = "/";
else if (response.status == 401) {
password: payload.data.password,
remember: payload.data.remember || false
}
}).then((r) => {
login_pending.value = false
navigateTo(useBasedPath("/"), { external: true})
}).catch((err) => {
if(err.data.statusCode == 401) {
login_pending.value = false
wrong_login.value = true;
// show for 5 seconds
setTimeout(() => wrong_login.value = false, 5000);
wrong_login_timeout = setTimeout(() => wrong_login.value = false, 5000);
}
})
}
@@ -68,7 +87,7 @@ function onSubmit(payload: FormSubmitEvent<Schema>) {
label: 'Login',
class: 'rounded-full',
variant: 'soft',
loadingAuto: true
loading: login_pending
}"
@submit="onSubmit"
>

View File

@@ -1,11 +1,100 @@
<script setup lang="ts">
definePageMeta({
layout: "settings",
middleware: ["login-required"]
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
useHead({
title: "Settings"
})
definePageMeta({
layout: "settings",
ssr: false, // force CSR
})
const toast = useToast()
const conf = useRuntimeConfig()
const base_url = conf.app.baseURL.endsWith('/') ? conf.app.baseURL.slice(0, -1) : conf.app.baseURL
const { data: user_data, refresh: userRefresh } = useFetch(base_url + "/me");
const loading_btn = ref(false)
const schema = z.object({
first_name: z.string(),
last_name: z.string(),
email: z.email()
})
type Schema = z.output<typeof schema>
async function submitChanges(event: FormSubmitEvent<Schema>) {
loading_btn.value = true
await fetch(useBasedPath("/settings/edit_user"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
first_name: user_data.value.first_name,
last_name: user_data.value.last_name,
email: user_data.value.email
})
}).then(async response => {
if (response.ok) {
toast.add({
title: "Done!",
description: "It's changed!",
color: "success"
})
}
else {
const body = await response.json()
toast.add({
title: 'Error',
description : body.data.detail || 'Something went wrong',
color: 'error'
})
}
})
loading_btn.value = false
}
</script>
<template>
<UContainer class="max-w-4xl">
<UForm :schema="schema" :state="user_data" @submit="submitChanges">
<div class="gap-3 justify-between">
<div class="flex-1 w-full gap-2 mt-5 mb-5">
<UFormField label="First Name" orientation="horizontal" name="first_name">
<UInput v-if="user_data" v-model="user_data.first_name" required/>
<USkeleton v-else class="w-53 h-7" />
</UFormField>
</div>
<div class="flex-1 w-full gap-2 mt-5 mb-5">
<UFormField label="Last Name" orientation="horizontal">
<UInput v-if="user_data" v-model="user_data.last_name" required/>
<USkeleton v-else class="w-53 h-7" />
</UFormField>
</div>
<div class="flex-1 w-full gap-2 mt-5 mb-5">
<UFormField label="E-mail" orientation="horizontal" name="email">
<UInput v-if="user_data" v-model="user_data.email" required/>
<USkeleton v-else class="w-53 h-7" />
</UFormField>
</div>
<div class="text-center justify-items-center">
<UButton variant="soft" icon="lucide:circle-check-big" :loading="loading_btn" type="submit" size="lg" :block="true" class="max-w-4/5 mt-4 mb-4 rounded-full">Submit</UButton>
</div>
</div>
</UForm>
</UContainer>
</template>
</template>
<style>
</style>

View File

@@ -1,7 +1,4 @@
<script setup lang="ts">
definePageMeta({
middleware: ["login-required"]
})
navigateTo("/settings/general");
</script>

View File

@@ -1,11 +1,149 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import urlJoin from 'url-join'
useHead({
title: "Security"
})
definePageMeta({
layout: "settings",
middleware: ["login-required"]
ssr: false // force CSR
})
const loading_btn = ref(false)
const toast = useToast()
const config = useRuntimeConfig()
//const base_url = config.app.baseURL.endsWith('/') ? config.app.baseURL.slice(0, -1) : config.app.baseURL
const schema = z.object({
old_pw: z.string(),
new_pw: z.string().min(8, "Min 8 chars"),
confirm_new_pw: z.string().min(8, "Min 8 chars"),
})
type Schema = z.output<typeof schema>
const pw_fields = ref({
old_pw: "",
new_pw: "",
confirm_new_pw: ""
})
const show_pw = reactive({
old_pw: false,
new_pw: false,
confirm_new_pw: false
})
async function submitChanges(event: FormSubmitEvent<Schema>) {
loading_btn.value = true
try {
fetch(useBasedPath("/settings/changepw"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(pw_fields.value)
}).then(async (response) => {
if (response.ok) {
toast.add({
title: "Done!",
description: "It's changed!",
color: "success"
})
}
else {
const body = await response.json()
console.log(body.statusMessage || "")
toast.add({
title: 'Error',
description : body.data?.detail || body.statusMessage || 'Something went wrong',
color: 'error'
})
}
})
}
catch(err:any) {
toast.add({
title: 'Error',
description: err?.data?.statusMessage || err.message || 'Something went wrong',
color: 'error'
})
}
loading_btn.value = false
}
</script>
<template>
<UContainer class="max-w-4xl">
<h1 class="text-lg ml-1 font-bold geist mb-1">Change password</h1>
<USeparator />
<div class="gap-3 justify-between">
<UForm :schema="schema" :state="pw_fields" @submit="submitChanges">
<div class="flex-1 w-full gap-2 mt-5 mb-5">
<UFormField label="Current password" orientation="horizontal" name="old_pw">
<UInput :type="show_pw.old_pw ? 'text' : 'password'" v-model="pw_fields.old_pw" required>
<template #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="show_pw.old_pw ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="show_pw.old_pw ? 'Hide password' : 'Show password'"
:aria-pressed="show_pw.old_pw"
aria-controls="password"
@click="show_pw.old_pw = !show_pw.old_pw"
/>
</template>
</UInput>
</UFormField>
</div>
<div class="flex-1 w-full gap-2 mt-5 mb-5">
<UFormField label="New password" orientation="horizontal" name="new_pw">
<UInput :type="show_pw.new_pw ? 'text' : 'password'" v-model="pw_fields.new_pw" required>
<template #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="show_pw.new_pw ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="show_pw.new_pw ? 'Hide password' : 'Show password'"
:aria-pressed="show_pw.new_pw"
aria-controls="password"
@click="show_pw.new_pw = !show_pw.new_pw"
/>
</template>
</UInput>
</UFormField>
</div>
<div class="flex-1 w-full gap-2 mt-5 mb-5">
<UFormField label="Confirm new password" orientation="horizontal" name="confirm_new_pw">
<UInput :type="show_pw.confirm_new_pw ? 'text' : 'password'" v-model="pw_fields.confirm_new_pw" required>
<template #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="show_pw.confirm_new_pw ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="show_pw.confirm_new_pw ? 'Hide password' : 'Show password'"
:aria-pressed="show_pw.confirm_new_pw"
aria-controls="password"
@click="show_pw.confirm_new_pw = !show_pw.confirm_new_pw"
/>
</template>
</UInput>
</UFormField>
</div>
<div class="text-center justify-items-center">
<UButton variant="soft" icon="lucide:circle-check-big" :loading="loading_btn" type="submit" size="lg" :block="true" class="max-w-4/5 mt-4 mb-4 rounded-full">Submit</UButton>
</div>
</UForm>
</div>
</UContainer>
</template>

View File

@@ -20,8 +20,14 @@ services:
#- bridge # allow external access
volumes:
- music_downloads:/tmp/downloads
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- DB_MODE=sqlite
- DB_MODE=mysql
- MYSQL_HOST=host.docker.internal
- MYSQL_USER=yt2mp3_nuxt
- MYSQL_PASSWORD=blowfish
- MYSQL_DB=yt2mp3_nuxt
nuxt:
build:
@@ -31,12 +37,12 @@ services:
networks:
- api_nuxt
ports:
- "64002:3030" # just for the admin GUI
- "6903:3000"
environment:
- FASTAPI_URL=http://fastapi:8000
- DOWNLOAD_FOLDER=/tmp/downloads
- NUXT_FASTAPI_URL=http://fastapi:8000
- NUXT_DOWNLOAD_FOLDER=/tmp/downloads
- NUXT_SESSION_PASSWORD=bc7d4da7d5f64d47aa75da92bed68cd8
- NUXT_APP_BASE_URL=/
volumes:
- music_downloads:/tmp/downloads
# These 3 are just for "nuxtjs-launcher"

View File

@@ -16,7 +16,11 @@ export default defineNuxtConfig({
plugins: [ tailwindcss() ]
},
runtimeConfig: {
fastapi_url: process.env.FASTAPI_URL || "http://127.0.0.1:8003",
download_path: process.env.DOWNLOAD_FOLDER || "/tmp/downloads"
// overridable by NUXT_variable
fastapi_url: "http://fastapi:8000",
download_path: "/tmp/downloads"
},
colorMode: {
disableTransition: false // Enable the animation
}
})

View File

@@ -22,6 +22,7 @@
"nuxt-auth-utils": "0.5.27",
"nuxt-lazytube": "0.2.5",
"tailwindcss": "^4.1.18",
"url-join": "^5.0.0",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"zod": "^4.3.5"

9
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
url-join:
specifier: ^5.0.0
version: 5.0.0
vue:
specifier: ^3.5.26
version: 3.5.26(typescript@5.9.3)
@@ -4271,6 +4274,10 @@ packages:
uqr@0.1.2:
resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==}
url-join@5.0.0:
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -9206,6 +9213,8 @@ snapshots:
uqr@0.1.2: {}
url-join@5.0.0: {}
util-deprecate@1.0.2: {}
vaul-vue@0.4.1(reka-ui@2.6.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,29 @@
import urlJoin from "url-join";
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
const conf = useRuntimeConfig(event)
const base_url = conf.app.baseURL
// normalize and remove base URL
function stripBaseUrl(fullPath: string) {
const base = base_url.replace(/\/+$/, '') // remove trailing slash
return fullPath.startsWith(base) ? fullPath.slice(base.length) || '/' : fullPath
}
let path = stripBaseUrl(event.node.req.url!)
if (path?.startsWith("/settings"))
// @ts-ignore
if (!session.secure?.user_id)
return sendRedirect(event, urlJoin(base_url,"/login"), 302);
else if (path?.startsWith("/login"))
// @ts-ignore
if (session.secure?.user_id)
return sendRedirect(event, urlJoin(base_url,"/"), 302);
return;
})

View File

@@ -1,5 +0,0 @@
export default defineEventHandler(async (event) => {
return;
// for now. this middleware is to be on redirect
})

9
server/py/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
]
}

3
server/py/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python-envs.pythonProjects": []
}

View File

@@ -0,0 +1,63 @@
from fastapi import Request, Response, HTTPException
from fastapi.responses import JSONResponse, PlainTextResponse
import aiosqlite
from user_mgmt import *
import db_setup as db
async def register_endpoint(req: Request):
body = await req.json()
try:
create_user(body["first_name"], body["last_name"], body["email"], body["password"])
except KeyError:
raise HTTPException(400, "Missing args")
async def login_endpoint(req: Request):
"""
The login endpoint
Takes email and password
Returns 401 or 400 in error.
Returns the user's full info if correct
"""
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)
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")

View File

@@ -0,0 +1,36 @@
import aiomysql, aiosqlite, os
from fastapi import FastAPI
from contextlib import asynccontextmanager
# Either "mysql" or "sqlite"
mode = os.environ.get("DB_MODE", "sqlite")
# For SQLite usage
sqlite_file = os.environ.get("SQLITE_FILE","./database.db")
# For MySQL usage
mysql_pool = None
mysql_host = os.environ.get("MYSQL_HOST","127.0.0.1")
mysql_user = os.environ.get("MYSQL_USER","yt2mp3")
mysql_db_name = os.environ.get("MYSQL_DB","yt2mp3")
mysql_pwd = os.environ.get("MYSQL_PASSWORD","")
@asynccontextmanager
async def lifespan(app: FastAPI):
if mode == "mysql":
try:
print("Going with mysql")
global mysql_pool
mysql_pool = await aiomysql.create_pool(host=mysql_host,user=mysql_user,password=mysql_pwd,db=mysql_db_name, minsize=1, maxsize=10)
yield
finally:
mysql_pool.close()
await mysql_pool.wait_closed()
elif mode == "sqlite":
try:
print("Going with sqlite")
yield
finally: pass # no action required now
else:
raise EnvironmentError("DB_MODE must either be mysql or sqlite")

View File

@@ -0,0 +1,6 @@
#!/bin/sh
# make sure it remains updated (don't crash if fails)
/usr/local/bin/pip install -U --no-cache-dir "yt-dlp[default]" || true
exec "$@"

View File

@@ -1,220 +1,155 @@
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
# src/main.py
from fastapi import FastAPI, HTTPException, status, Request
from fastapi.responses import JSONResponse, PlainTextResponse
import os
import bcrypt
import asyncio
import aiosqlite
import subprocess
import logging
import uvicorn
import copy
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
#from pytube import YouTube
#from moviepy.audio.io import AudioFileClip
import db_setup as db # Import the entire db_setup module
from user_mgmt import *
from media_mgmt import *
#import asyncio
pathRoot = os.environ.get("ROOT_PATH","/")
# In this FastAPI example, we skip BaseModel verification cause NuxtJS does that for us
# In other words, this backend only recieves requests from nuxt js backend
# So we can trust it
app = FastAPI(
title="YT2MP3 API",
description="Simple API to download YouTube videos as MP3.",
version="1.0.0",
license_info="MIT",
root_path=pathRoot,
lifespan=db.lifespan
)
class db:
# Global error handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.exception(f"An unexpected error occurred: {exc}")
return JSONResponse(content={"error": "Internal Server Error"}, status_code=500)
mode = os.environ.get("DB_MODE", "sqlite")
# Health check endpoint
@app.get("/health", status_code=status.HTTP_200_OK)
async def health_check():
return {"status": "ok"}
# 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()
#
# User-related endpoints
#
@app.get("/me")
async def me_endpoint(req: Request):
query = dict(req.query_params)
print(query)
if "id" not in query.keys(): raise HTTPException(400, "Give ID")
user = await fetch_user_info(query["id"],"id")
if user is None: raise HTTPException(404, "User not found")
else:
try:
yield
finally: pass # no action required now
app = FastAPI(lifespan=lifespan)
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()
def download_audio(video_url: str, output_folder: str) -> str:
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': os.path.join(output_folder, '%(title)s.%(ext)s'),
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}, {
'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
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
# Configuration (customize these!)
DOWNLOAD_FOLDER = os.environ.get("DOWNLOAD_FOLDER","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")
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")
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 = 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)
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()
# Auto exceptions give more verbose to Nuxt.
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()
return user
@app.post("/login")
async def login_endpoint(req: Request):
body = await req.json()
#print(body)
needed_keys = ("email","password")
for key in needed_keys:
if body.get(key) is None:
raise HTTPException(400, "Missing args")
login_obj = await login_user(body["email"], body["password"])
if login_obj is None:
raise HTTPException(401, "Incorrect login")
else:
return login_obj
@app.post("/register")
async def register_endpoint(req: Request):
body = await req.json()
for key in ("email","password", "first_name", "last_name"):
if body.get(key) is None:
raise HTTPException(400, "Missing args")
# check if user exists.
if await fetch_user_info(body["email"],"email") is not None:
raise HTTPException(400, "User already exists")
await create_user(body["first_name"], body["last_name"], body["email"], body["password"])
return PlainTextResponse("User registered")
@app.post("/edit_user")
async def edit_endpoint(req: Request):
body:dict = await req.json()
print(body)
if "user_id" not in body.keys(): raise HTTPException(400, "Provide user ID")
params = copy.deepcopy(body) # to create a completely new version (ridiculous but okay)
params.pop("user_id")
await edit_user(body["user_id"], params)
return PlainTextResponse("User edited")
@app.post("/changepw")
async def changepw_endpoint(req: Request):
body:dict = await req.json()
print(body)
if "user_id" not in body.keys(): raise HTTPException(400, "Provide user ID")
params = copy.deepcopy(body) # to create a completely new version (ridiculous but okay)
params.pop("user_id")
params.pop("old_password")
user = await fetch_user_info(body["user_id"],"id")
if bcrypt.checkpw(body["old_password"].encode(), user["password"].encode()) == False:
raise HTTPException(403, "Incorrect password")
await edit_user(body["user_id"], params)
return PlainTextResponse("Password changed")
#
# Track-specific endpoints
#
@app.post("/convert")
async def begin_conversion(req: Request):
body:dict = await req.json()
video_url = body.get("video_url")
user_id = body.get("user_id")
quality = body.get("kbps", 256)
if (video_url is None): raise HTTPException(400, "Please provide an URL on body")
if (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:
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")
file = await asyncio.to_thread(download_audio, video_url, USER_FOLDER, quality)
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 }
except KeyError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Missing parameter: {e}")
except DownloadError as e:
logger.exception(f"Download error: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to download video")
except Exception as e:
logger.exception(f"Error during media conversion: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to convert media")
if __name__ == "__main__":
listen_host = str(os.environ.get("HOST", "127.0.0.1"))

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)

View File

@@ -0,0 +1,73 @@
import subprocess, os, asyncio
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
#
# 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)
#
# 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

View File

@@ -1,16 +0,0 @@
aiomysql==0.3.2
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
bcrypt==5.0.0
click==8.3.1
fastapi==0.128.0
h11==0.16.0
idna==3.11
pydantic==2.12.5
pydantic_core==2.41.5
starlette==0.50.0
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.40.0

11
server/py/fastapi_server/requirements_prod.txt Normal file → Executable file
View File

@@ -4,16 +4,25 @@ annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
bcrypt==5.0.0
brotli==1.2.0
certifi==2026.1.4
charset-normalizer==3.4.4
click==8.3.1
fastapi==0.128.0
h11==0.16.0
idna==3.11
mutagen==1.47.0
pycryptodomex==3.23.0
pydantic==2.12.5
pydantic_core==2.41.5
PyMySQL==1.1.2
requests==2.32.5
starlette==0.50.0
typing==3.7.4.3
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.40.0
yt-dlp==2025.12.8
websockets==16.0
yt-dlp==2026.1.29
yt-dlp-ejs==0.4.0

View File

@@ -0,0 +1,79 @@
# fastapi_server/user_mgmt.py
import db_setup as db
import bcrypt
import asyncio
import aiomysql
import db_setup as db
async def fetch_user_info(arg: str, type: str):
"""Fetches user info.
This kind of query is supposed to either return ONE user or nothing.
"""
if db.mode == "mysql":
async with db.mysql_pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"SELECT * FROM users WHERE {type} = %s", (arg,))
#print(cur.description)
return await cur.fetchone()
elif db.mode == "sqlite":
async with db.file.connect() as conn:
async with conn.execute(f"SELECT * FROM users WHERE {type} = ?", (arg,)) as cursor:
return await cursor.fetchone()
return None
async def create_user(first_name: str, last_name: str, email: str, password: str):
"""Creates a new user."""
hashed_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
if db.mode == "mysql":
async with db.mysql_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)", (first_name, last_name, email, hashed_password.decode()))
await conn.commit()
return True
elif db.mode == "sqlite":
async with db.file.connect() as conn:
await conn.execute("INSERT INTO users (`first_name`,`last_name`,`email`,`password`) VALUES (?, ?, ?, ?)", (first_name, last_name, email, hashed_password.decode()))
await conn.commit()
return True
return False
async def login_user(email: str, password: str):
"""Logs in a user."""
user_data = await fetch_user_info(email, "email")
print(user_data)
if user_data is None:
return None # User doesn't exist
else:
if bcrypt.checkpw(password.encode(), user_data.get("password").encode()):
return user_data # Login successful
else:
return None # Incorrect password
async def edit_user(user_id: int, updates: dict):
"""Edits an existing user."""
if db.mode == "mysql":
placeholders = ", ".join(["%s"] * len(updates))
if updates.get("password") is not None: # there is a password update
updates["password"] = bcrypt.hashpw(updates["password"].encode(), bcrypt.gensalt()).decode()
values = list(updates.values())
query = f"UPDATE users SET {', '.join([f'{key} = %s' for key in updates.keys()])} WHERE id = %s"
values.append(user_id)
async with db.mysql_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(query, values)
await conn.commit()
return True
elif db.mode == "sqlite":
placeholders = ", ".join(["?"] * len(updates))
values = list(updates.values())
values.append(user_id)
query = f"UPDATE users SET {', '.join([f'{key} = ?' for key in updates.keys()])} WHERE id = ?"
async with db.file.connect() as conn:
await conn.execute(query, values)
await conn.commit()
return True
return False

View File

@@ -9,9 +9,9 @@ export default defineEventHandler(async (event) => {
const userId = session.secure?.user_id;
if (userId == undefined || userId == null)
throw createError({ status: 401 })
throw createError({ statusCode: 401 })
else if (body.song == undefined)
throw createError({ status: 400, message: "Song name required"})
throw createError({ statusCode: 400, statusMessage: "Song name required"})
const req = await fetch(config.fastapi_url + "/convert", {
method: "POST",

View File

@@ -1,13 +1,33 @@
import * as z from "zod"
export default defineEventHandler(async (event) => {
const conf = useRuntimeConfig()
const body = await readBody(event)
console.log(body)
const schema = z.object({
email: z.email('Invalid email'),
password: z.string('Password is required').min(8, 'Must be at least 8 characters'),
remember: z.boolean().optional()
})
let validated;
try {
validated = schema.parse(body); // throws if invalid
} catch (err: any) {
// Zod throws a ZodError
return sendError(event, createError({
statusCode: 400,
statusMessage: err.errors?.[0]?.message || 'Invalid input'
}));
}
const is_https = await getRequestProtocol(event) == "https"
//console.log(is_https)
if ( !body.email || !body.password )
throw createError({ statusCode: 400, message: "Bad form. Use e-mail and password" })
throw createError({ statusCode: 400, statusMessage: "Bad form. Use e-mail and password" })
//console.log(body)
const req = await fetch(conf.fastapi_url + "/login",
{
@@ -22,7 +42,7 @@ export default defineEventHandler(async (event) => {
}
)
if (!req.ok) throw createError({ statusCode: req.status })
if (!req.ok) throw createError({ statusCode: req.status, statusMessage: "Error"})
else {
const body = await req.json();
@@ -35,7 +55,9 @@ export default defineEventHandler(async (event) => {
secure: {
user_id: body.id
}
}, {
},
{
maxAge: (body.rembember) ? 60 * 60 * 24 * 7 : undefined,
cookie: {
secure: is_https,
sameSite: is_https ? 'none' : 'lax'

View File

@@ -1,6 +1,6 @@
export default defineEventHandler(async (event) => {
// log them out
console.log(await clearUserSession(event))
await clearUserSession(event)
return;
})

23
server/routes/me.get.ts Normal file
View File

@@ -0,0 +1,23 @@
export default defineEventHandler(async (event) => {
const config = await useRuntimeConfig(event)
//const body = await readBody(event)
const session = await getUserSession(event)
// @ts-ignore
const userId = session.secure?.user_id;
if (userId == undefined || userId == null)
throw createError({ statusCode: 401 })
const r = await fetch(config.fastapi_url + "/me" + '?' +
new URLSearchParams({ id: userId }))
let body = await r.json();
try { delete body["id"]; } catch {}
try { delete body["password"]; } catch {}
try { delete body["created_at"] } catch {}
try { delete body["updated_at"]; } catch {}
return body;
})

View File

@@ -0,0 +1,49 @@
import * as z from "zod"
export default defineEventHandler(async (event) => {
const body = await readBody(event)
if (body.new_pw !== body.confirm_new_pw)
throw createError({statusCode: 400, statusMessage: "Passwords don't match!"})
const session = await getUserSession(event)
const config = await useRuntimeConfig(event)
const schema = z.object({
old_pw: z.string(),
new_pw: z.string().min(8, "Min 8 chars"),
confirm_new_pw: z.string().min(8, "Min 8 chars"),
})
let validated;
try {
validated = schema.parse(body); // throws if invalid
} catch (err: any) {
// Zod throws a ZodError
return sendError(event, createError({
statusCode: 400,
statusMessage: err.errors?.[0]?.message || 'Invalid input'
}));
}
// @ts-ignore
const userId = session.secure?.user_id;
if (userId == undefined || userId == null)
throw createError({ status: 401 })
const current_r = await fetch(config.fastapi_url + "/me" + '?' +
new URLSearchParams({ id: userId }))
if (!current_r.ok)
if (current_r.status == 404)
throw createError({statusCode: 404, statusMessage: "User doesn't exist"})
await $fetch(config.fastapi_url + "/changepw", {
method: "POST",
body: {
user_id: userId,
old_password: body.old_pw,
password: body.new_pw
}
})
})

View File

@@ -0,0 +1,59 @@
import * as z from "zod"
export default defineEventHandler(async (event) => {
const config = await useRuntimeConfig(event)
let body = await readBody(event)
console.log(body)
const session = await getUserSession(event)
const schema = z.object({
first_name: z.string(),
last_name: z.string(),
email: z.email()
})
let validated;
try {
validated = schema.parse(body); // throws if invalid
} catch (err: any) {
// Zod throws a ZodError
return sendError(event, createError({
statusCode: 400,
statusMessage: err.errors?.[0]?.message || 'Invalid input'
}));
}
// @ts-ignore
const userId = session.secure?.user_id;
if (userId == undefined || userId == null)
throw createError({ status: 401 })
const current_r = await fetch(config.fastapi_url + "/me" + '?' +
new URLSearchParams({ id: userId }))
if (!current_r.ok)
if (current_r.status == 404)
throw createError({ statusCode: 404, statusMessage: "User not found"})
const current_body = await current_r.json();
try { delete current_body["id"]; } catch {}
try { delete current_body["password"]; } catch {}
try { delete current_body["created_at"] } catch {}
try { delete current_body["updated_at"]; } catch {}
//let differences: Record<string, any> = {};
let a = { ...body };
a.user_id = userId!
console.log(a)
//console.log("Differences: " + JSON.stringify(differences));
return await $fetch(config.fastapi_url + "/edit_user", {
method: "POST",
body: a,
ignoreResponseError: true // don't throw exception
})
})