Compare commits
40 Commits
v1.0.0
...
internal-d
| Author | SHA1 | Date | |
|---|---|---|---|
| eb35a99c3e | |||
| 61233b3611 | |||
| 4ec441e688 | |||
| 263ec699ad | |||
| 6a1eef0f76 | |||
| ea27e4e8d5 | |||
| 50ba23fdb4 | |||
| e66c7dfb89 | |||
| c10f71b2db | |||
| 1ecab34d79 | |||
| 3d2bcc8ea6 | |||
| 4338f347c5 | |||
| 3eeaa797ad | |||
| 170d34c08c | |||
| 6edbf55756 | |||
| 1ac08fd656 | |||
| 3892c5ea4e | |||
| 4ed1fb9401 | |||
| 501580b2e0 | |||
| 048bcd1b5a | |||
| 2577dc46ee | |||
| c9189c4ef1 | |||
| be678c1cf0 | |||
| c020ea968c | |||
| 8486b95a45 | |||
| b3e14c1e02 | |||
| a9a6de8738 | |||
| 2c5c34eeac | |||
| 45448862c7 | |||
| 9670293b51 | |||
| 16e5d79460 | |||
| caf80f24ef | |||
| c0a1a0b944 | |||
| 5d39a150e7 | |||
| f7623483f8 | |||
| fb654afd11 | |||
| 3663061e02 | |||
| accf35e364 | |||
| 797a696aff | |||
| 8ecadcf2e5 |
@@ -9,7 +9,9 @@ RUN pip install --no-cache-dir -r requirements_prod.txt
|
|||||||
#RUN apt update && apt install ffmpeg -y
|
#RUN apt update && apt install ffmpeg -y
|
||||||
|
|
||||||
#COPY server/py/fastapi_server/ ./
|
#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 PORT=8000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
@@ -18,6 +20,10 @@ ENV DOWNLOAD_FOLDER=/tmp/downloads
|
|||||||
# Ammount (in secs) before file autodeletes
|
# Ammount (in secs) before file autodeletes
|
||||||
ENV FILE_AUTODELETE_DELAY_SECS=300
|
ENV FILE_AUTODELETE_DELAY_SECS=300
|
||||||
|
|
||||||
|
ENV ROOT_PATH=/
|
||||||
|
|
||||||
VOLUME ["/tmp/downloads"]
|
VOLUME ["/tmp/downloads"]
|
||||||
|
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["python3", "main.py"]
|
CMD ["python3", "main.py"]
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ ENV HOST=0.0.0.0
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV DOWNLOAD_FOLDER=/tmp/downloads
|
ENV NUXT_DOWNLOAD_FOLDER=/tmp/downloads
|
||||||
VOLUME ["/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"]
|
CMD ["node", "/app/server/index.mjs"]
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ui_colors_dropdown } from '~/utils/set_colors';
|
|
||||||
|
|
||||||
const loggedIn = ref(false);
|
const loggedIn = ref(false);
|
||||||
const logout_prompt = ref(false);
|
const logout_prompt = ref(false);
|
||||||
@@ -27,16 +26,13 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
return fetch("/logout").then(
|
return $fetch("/logout").then((r) => {
|
||||||
(response) => {
|
reloadNuxtApp({ path: useBasedPath("/") })
|
||||||
if (response.ok) {
|
});
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<UHeader :toggle="false" class="pop mb-4">
|
<UHeader :toggle="false" class="pop mb-4">
|
||||||
|
|||||||
6
app/composables/useBasedPath.ts
Normal file
6
app/composables/useBasedPath.ts
Normal 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
7
app/layouts/default.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
<UToaster />
|
||||||
|
</template>
|
||||||
@@ -20,4 +20,5 @@
|
|||||||
<MainHeader class="mb-0" :settings_link="false" :logged_in="loggedIn"/>
|
<MainHeader class="mb-0" :settings_link="false" :logged_in="loggedIn"/>
|
||||||
<UNavigationMenu :items="settings_submenu" class="justify-center mt-2 mb-2"/>
|
<UNavigationMenu :items="settings_submenu" class="justify-center mt-2 mb-2"/>
|
||||||
<slot />
|
<slot />
|
||||||
|
<UToaster />
|
||||||
</template>
|
</template>
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
import urlJoin from 'url-join';
|
||||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
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 video = ref(0);
|
||||||
const url = ref("");
|
const url = ref("");
|
||||||
const in_url = ref("");
|
const in_url = ref("");
|
||||||
|
const dwn_link = ref("");
|
||||||
|
|
||||||
const file_dwnl = ref("");
|
const file_dwnl = ref("");
|
||||||
|
|
||||||
@@ -34,34 +46,38 @@
|
|||||||
|
|
||||||
|
|
||||||
async function start_conversion(event: FormSubmitEvent<musicParamsSchema>) {
|
async function start_conversion(event: FormSubmitEvent<musicParamsSchema>) {
|
||||||
return fetch("/convert", {
|
return $fetch("/convert", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
body: {
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
url: url.value,
|
url: url.value,
|
||||||
song: music_params.song,
|
song: music_params.song,
|
||||||
artist: music_params.artist,
|
artist: music_params.artist,
|
||||||
album: music_params.album
|
album: music_params.album
|
||||||
})
|
}
|
||||||
}).then(async r => {
|
}).then(r => { // r resolves to { file: "my_audio.mp3" }
|
||||||
//console.log(r.status)
|
const file = r.file; // in this case r["file"] --> "my_audio.mp3"
|
||||||
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");
|
const a = document.createElement("a");
|
||||||
a.href = file_dwnl.value;
|
a.href = useBasedPath(`/download/${file}`)
|
||||||
a.download = file; // sets suggested filename
|
a.download = file; // sets suggested filename
|
||||||
|
file_dwnl.value = a.href; // this ref activates the Download button
|
||||||
document.body.appendChild(a); // must append first
|
document.body.appendChild(a); // must append first
|
||||||
a.click(); // triggers download
|
a.click(); // triggers download
|
||||||
a.remove();
|
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>
|
</script>
|
||||||
@@ -75,10 +91,10 @@
|
|||||||
<UInput
|
<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"
|
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' }"
|
: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" >
|
v-model="in_url" >
|
||||||
<template #trailing>
|
<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>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
|
|
||||||
@@ -103,7 +119,7 @@
|
|||||||
<div class="text-center">
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</UForm>
|
</UForm>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import type { FormSubmitEvent, AuthFormField } from '@nuxt/ui'
|
import type { FormSubmitEvent, AuthFormField } from '@nuxt/ui'
|
||||||
|
import urlJoin from 'url-join'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Login"
|
||||||
|
})
|
||||||
|
definePageMeta({
|
||||||
|
ssr: false // CSR
|
||||||
|
})
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// animation & status refs
|
||||||
const wrong_login = ref(false)
|
const wrong_login = ref(false)
|
||||||
|
const login_pending = ref(false)
|
||||||
|
|
||||||
|
// variable of timeout type
|
||||||
|
let wrong_login_timeout : ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
const fields: AuthFormField[] = [{
|
const fields: AuthFormField[] = [{
|
||||||
name: 'email',
|
name: 'email',
|
||||||
@@ -29,28 +43,33 @@ const fields: AuthFormField[] = [{
|
|||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.email('Invalid email'),
|
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>
|
type Schema = z.output<typeof schema>
|
||||||
|
|
||||||
function onSubmit(payload: FormSubmitEvent<Schema>) {
|
async function onSubmit(payload: FormSubmitEvent<Schema>) {
|
||||||
return fetch("/login", {
|
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",
|
method: "POST",
|
||||||
headers: {
|
body: {
|
||||||
"Content-Type" : "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: payload.data.email,
|
email: payload.data.email,
|
||||||
password: payload.data.password
|
password: payload.data.password,
|
||||||
})
|
remember: payload.data.remember || false
|
||||||
}).then((response) => {
|
}
|
||||||
if (response.ok)
|
}).then((r) => {
|
||||||
window.location.href = "/";
|
login_pending.value = false
|
||||||
else if (response.status == 401) {
|
navigateTo(useBasedPath("/"), { external: true})
|
||||||
|
}).catch((err) => {
|
||||||
|
if(err.data.statusCode == 401) {
|
||||||
|
login_pending.value = false
|
||||||
wrong_login.value = true;
|
wrong_login.value = true;
|
||||||
// show for 5 seconds
|
// 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',
|
label: 'Login',
|
||||||
class: 'rounded-full',
|
class: 'rounded-full',
|
||||||
variant: 'soft',
|
variant: 'soft',
|
||||||
loadingAuto: true
|
loading: login_pending
|
||||||
}"
|
}"
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
import * as z from 'zod'
|
||||||
layout: "settings",
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
middleware: ["login-required"]
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
|
||||||
middleware: ["login-required"]
|
|
||||||
})
|
|
||||||
navigateTo("/settings/general");
|
navigateTo("/settings/general");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,149 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import * as z from 'zod'
|
||||||
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import urlJoin from 'url-join'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Security"
|
||||||
|
})
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "settings",
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</template>
|
||||||
@@ -20,8 +20,14 @@ services:
|
|||||||
#- bridge # allow external access
|
#- bridge # allow external access
|
||||||
volumes:
|
volumes:
|
||||||
- music_downloads:/tmp/downloads
|
- music_downloads:/tmp/downloads
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
- DB_MODE=sqlite
|
- DB_MODE=mysql
|
||||||
|
- MYSQL_HOST=host.docker.internal
|
||||||
|
- MYSQL_USER=yt2mp3_nuxt
|
||||||
|
- MYSQL_PASSWORD=blowfish
|
||||||
|
- MYSQL_DB=yt2mp3_nuxt
|
||||||
|
|
||||||
nuxt:
|
nuxt:
|
||||||
build:
|
build:
|
||||||
@@ -31,12 +37,12 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- api_nuxt
|
- api_nuxt
|
||||||
ports:
|
ports:
|
||||||
- "64002:3030" # just for the admin GUI
|
|
||||||
- "6903:3000"
|
- "6903:3000"
|
||||||
environment:
|
environment:
|
||||||
- FASTAPI_URL=http://fastapi:8000
|
- NUXT_FASTAPI_URL=http://fastapi:8000
|
||||||
- DOWNLOAD_FOLDER=/tmp/downloads
|
- NUXT_DOWNLOAD_FOLDER=/tmp/downloads
|
||||||
- NUXT_SESSION_PASSWORD=bc7d4da7d5f64d47aa75da92bed68cd8
|
- NUXT_SESSION_PASSWORD=bc7d4da7d5f64d47aa75da92bed68cd8
|
||||||
|
- NUXT_APP_BASE_URL=/
|
||||||
volumes:
|
volumes:
|
||||||
- music_downloads:/tmp/downloads
|
- music_downloads:/tmp/downloads
|
||||||
# These 3 are just for "nuxtjs-launcher"
|
# These 3 are just for "nuxtjs-launcher"
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export default defineNuxtConfig({
|
|||||||
plugins: [ tailwindcss() ]
|
plugins: [ tailwindcss() ]
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
fastapi_url: process.env.FASTAPI_URL || "http://127.0.0.1:8003",
|
// overridable by NUXT_variable
|
||||||
download_path: process.env.DOWNLOAD_FOLDER || "/tmp/downloads"
|
fastapi_url: "http://fastapi:8000",
|
||||||
|
download_path: "/tmp/downloads"
|
||||||
|
},
|
||||||
|
colorMode: {
|
||||||
|
disableTransition: false // Enable the animation
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"nuxt-auth-utils": "0.5.27",
|
"nuxt-auth-utils": "0.5.27",
|
||||||
"nuxt-lazytube": "0.2.5",
|
"nuxt-lazytube": "0.2.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"url-join": "^5.0.0",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18
|
version: 4.1.18
|
||||||
|
url-join:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.26
|
specifier: ^3.5.26
|
||||||
version: 3.5.26(typescript@5.9.3)
|
version: 3.5.26(typescript@5.9.3)
|
||||||
@@ -4271,6 +4274,10 @@ packages:
|
|||||||
uqr@0.1.2:
|
uqr@0.1.2:
|
||||||
resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==}
|
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:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -9206,6 +9213,8 @@ snapshots:
|
|||||||
|
|
||||||
uqr@0.1.2: {}
|
uqr@0.1.2: {}
|
||||||
|
|
||||||
|
url-join@5.0.0: {}
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
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)):
|
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 |
29
server/middleware/redirects.ts
Normal file
29
server/middleware/redirects.ts
Normal 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;
|
||||||
|
})
|
||||||
@@ -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
9
server/py/.vscode/launch.json
vendored
Normal 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
3
server/py/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"python-envs.pythonProjects": []
|
||||||
|
}
|
||||||
63
server/py/fastapi_server/api.py
Normal file
63
server/py/fastapi_server/api.py
Normal 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")
|
||||||
36
server/py/fastapi_server/db_setup.py
Normal file
36
server/py/fastapi_server/db_setup.py
Normal 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")
|
||||||
6
server/py/fastapi_server/entrypoint.sh
Normal file
6
server/py/fastapi_server/entrypoint.sh
Normal 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 "$@"
|
||||||
@@ -1,146 +1,139 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
# src/main.py
|
||||||
#from fastapi import Body, Cookie, File, Form, Header, Path, Query
|
from fastapi import FastAPI, HTTPException, status, Request
|
||||||
from contextlib import asynccontextmanager
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||||
from pydantic import BaseModel, ValidationError
|
|
||||||
from typing import Optional
|
|
||||||
import uvicorn
|
|
||||||
import aiomysql
|
|
||||||
import os
|
import os
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import asyncio
|
import asyncio
|
||||||
import aiosqlite
|
import logging
|
||||||
import subprocess
|
import uvicorn
|
||||||
|
import copy
|
||||||
|
|
||||||
from yt_dlp import YoutubeDL
|
# Configure logging
|
||||||
from yt_dlp.utils import DownloadError
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#from pytube import YouTube
|
import db_setup as db # Import the entire db_setup module
|
||||||
#from moviepy.audio.io import AudioFileClip
|
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
|
app = FastAPI(
|
||||||
# In other words, this backend only recieves requests from nuxt js backend
|
title="YT2MP3 API",
|
||||||
# So we can trust it
|
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
|
#
|
||||||
|
# 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")
|
||||||
|
|
||||||
file = os.environ.get("SQLITE_FILE","./database.db")
|
user = await fetch_user_info(query["id"],"id")
|
||||||
|
if user is None: raise HTTPException(404, "User not found")
|
||||||
# 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:
|
else:
|
||||||
try:
|
return user
|
||||||
yield
|
|
||||||
finally: pass # no action required now
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
@app.post("/login")
|
||||||
|
async def login_endpoint(req: Request):
|
||||||
|
|
||||||
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()
|
body = await req.json()
|
||||||
|
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)
|
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")
|
video_url = body.get("video_url")
|
||||||
user_id = body.get("user_id")
|
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 (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")
|
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))
|
USER_FOLDER = os.path.join(DOWNLOAD_FOLDER, str(user_id))
|
||||||
if not os.path.exists(USER_FOLDER): os.mkdir(USER_FOLDER)
|
if not os.path.exists(USER_FOLDER): os.mkdir(USER_FOLDER)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file = await asyncio.to_thread(download_audio, video_url, USER_FOLDER)
|
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"))
|
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]
|
file_name = final_file.split("/")[-1]
|
||||||
|
|
||||||
@@ -148,72 +141,14 @@ async def create_conversion(req: Request):
|
|||||||
asyncio.create_task(delay_delete_file(final_file))
|
asyncio.create_task(delay_delete_file(final_file))
|
||||||
|
|
||||||
return { "file" : file_name }
|
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:
|
||||||
@app.post("/register")
|
logger.exception(f"Download error: {e}")
|
||||||
async def create_user(req: Request):
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to download video")
|
||||||
body = await req.json()
|
except Exception as e:
|
||||||
|
logger.exception(f"Error during media conversion: {e}")
|
||||||
# Auto exceptions give more verbose to Nuxt.
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to convert media")
|
||||||
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
234
server/py/fastapi_server/main.py.bak
Normal file
234
server/py/fastapi_server/main.py.bak
Normal 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)
|
||||||
73
server/py/fastapi_server/media_mgmt.py
Normal file
73
server/py/fastapi_server/media_mgmt.py
Normal 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
|
||||||
|
|
||||||
@@ -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
11
server/py/fastapi_server/requirements_prod.txt
Normal file → Executable file
@@ -4,16 +4,25 @@ annotated-doc==0.0.4
|
|||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
anyio==4.12.1
|
anyio==4.12.1
|
||||||
bcrypt==5.0.0
|
bcrypt==5.0.0
|
||||||
|
brotli==1.2.0
|
||||||
|
certifi==2026.1.4
|
||||||
|
charset-normalizer==3.4.4
|
||||||
click==8.3.1
|
click==8.3.1
|
||||||
fastapi==0.128.0
|
fastapi==0.128.0
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
|
mutagen==1.47.0
|
||||||
|
pycryptodomex==3.23.0
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
pydantic_core==2.41.5
|
pydantic_core==2.41.5
|
||||||
PyMySQL==1.1.2
|
PyMySQL==1.1.2
|
||||||
|
requests==2.32.5
|
||||||
starlette==0.50.0
|
starlette==0.50.0
|
||||||
typing==3.7.4.3
|
typing==3.7.4.3
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.6.3
|
||||||
uvicorn==0.40.0
|
uvicorn==0.40.0
|
||||||
yt-dlp==2025.12.8
|
websockets==16.0
|
||||||
|
yt-dlp==2026.1.29
|
||||||
|
yt-dlp-ejs==0.4.0
|
||||||
|
|||||||
79
server/py/fastapi_server/user_mgmt.py
Normal file
79
server/py/fastapi_server/user_mgmt.py
Normal 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
|
||||||
@@ -9,9 +9,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const userId = session.secure?.user_id;
|
const userId = session.secure?.user_id;
|
||||||
if (userId == undefined || userId == null)
|
if (userId == undefined || userId == null)
|
||||||
throw createError({ status: 401 })
|
throw createError({ statusCode: 401 })
|
||||||
else if (body.song == undefined)
|
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", {
|
const req = await fetch(config.fastapi_url + "/convert", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
|
import * as z from "zod"
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const conf = useRuntimeConfig()
|
const conf = useRuntimeConfig()
|
||||||
|
|
||||||
const body = await readBody(event)
|
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"
|
const is_https = await getRequestProtocol(event) == "https"
|
||||||
//console.log(is_https)
|
//console.log(is_https)
|
||||||
|
|
||||||
if ( !body.email || !body.password )
|
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)
|
//console.log(body)
|
||||||
const req = await fetch(conf.fastapi_url + "/login",
|
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 {
|
else {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
@@ -35,7 +55,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
secure: {
|
secure: {
|
||||||
user_id: body.id
|
user_id: body.id
|
||||||
}
|
}
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
|
maxAge: (body.rembember) ? 60 * 60 * 24 * 7 : undefined,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: is_https,
|
secure: is_https,
|
||||||
sameSite: is_https ? 'none' : 'lax'
|
sameSite: is_https ? 'none' : 'lax'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// log them out
|
// log them out
|
||||||
console.log(await clearUserSession(event))
|
await clearUserSession(event)
|
||||||
|
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
23
server/routes/me.get.ts
Normal file
23
server/routes/me.get.ts
Normal 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;
|
||||||
|
})
|
||||||
49
server/routes/settings/changepw.post.ts
Normal file
49
server/routes/settings/changepw.post.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
59
server/routes/settings/edit_user.post.ts
Normal file
59
server/routes/settings/edit_user.post.ts
Normal 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
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user