Add loader and new formats
Some checks failed
Bump deps (only minor versions) / ci (push) Failing after 11s
Some checks failed
Bump deps (only minor versions) / ci (push) Failing after 11s
This commit is contained in:
parent
204bceeee3
commit
dcb7cfec27
6 changed files with 148 additions and 40 deletions
6
src/lib/common/supportedFormats.json
Normal file
6
src/lib/common/supportedFormats.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"opus": "audio/ogg",
|
||||||
|
"wav": "audio/wav"
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
<div
|
<div
|
||||||
class="bg-opacity-50 absolute inset-0 z-10 flex items-center justify-center bg-white"
|
class=" backdrop-blur-xs bg-black/20 absolute inset-0 z-10 flex items-center justify-center "
|
||||||
id="spinner"
|
id="spinner"
|
||||||
>
|
>
|
||||||
<div class="h-20 w-20 animate-spin rounded-full border-t-2 border-b-2 border-gray-900"></div>
|
<div class="w-[150px]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><linearGradient id="a3"><stop offset="0" stop-color="#FF156D" stop-opacity="0"></stop><stop offset="1" stop-color="#FF156D"></stop></linearGradient><circle fill="none" stroke="url(#a3)" stroke-width="15" stroke-linecap="round" stroke-dasharray="0 44 0 44 0 44 0 44 0 360" cx="100" cy="100" r="70" transform-origin="center"><animateTransform type="rotate" attributeName="transform" calcMode="discrete" dur="2" values="360;324;288;252;216;180;144;108;72;36" repeatCount="indefinite"></animateTransform></circle></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
14
src/lib/server/helpers.ts
Normal file
14
src/lib/server/helpers.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import formats from '$lib/common/supportedFormats.json';
|
||||||
|
|
||||||
|
const formatMime = new Map(Object.entries(formats))
|
||||||
|
|
||||||
|
export const mimeTypeMap = formatMime;
|
||||||
|
export const contentTypeFromFormat = (format: string): string => {
|
||||||
|
const toReturn: string | undefined = formatMime.get(format)
|
||||||
|
|
||||||
|
if (!toReturn) {
|
||||||
|
throw new Error("Unsupported format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
import { create } from 'youtube-dl-exec';
|
import { create } from 'youtube-dl-exec';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import supportedFormats from '$lib/common/supportedFormats.json';
|
||||||
|
import { mimeTypeMap } from '$lib/server/helpers';
|
||||||
|
|
||||||
const YTDLP_PATH: string = env.YTDLP_PATH as string;
|
const YTDLP_PATH: string = env.YTDLP_PATH as string;
|
||||||
|
|
||||||
export const ytdl = create(YTDLP_PATH);
|
export const ytdl = create(YTDLP_PATH);
|
||||||
|
@ -21,19 +24,27 @@ export async function getYouTubeMetadata(link: string) {
|
||||||
* Streams the YouTube video/audio using youtube-dl-exec
|
* Streams the YouTube video/audio using youtube-dl-exec
|
||||||
*/
|
*/
|
||||||
export function streamYouTube(link: string, format: string): ReadableStream<Uint8Array> {
|
export function streamYouTube(link: string, format: string): ReadableStream<Uint8Array> {
|
||||||
|
const mimeType: string | undefined = mimeTypeMap.get(format)
|
||||||
|
|
||||||
|
if (!mimeType) {
|
||||||
|
throw new Error("Unsupported format");
|
||||||
|
}
|
||||||
|
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const args = [
|
const args = [
|
||||||
'-o',
|
'-o',
|
||||||
'-',
|
'-',
|
||||||
format === 'mp3' ? '--embed-metadata' : '',
|
|
||||||
'--format',
|
|
||||||
format === 'mp3' ? 'bestaudio' : 'best',
|
|
||||||
'--audio-format',
|
|
||||||
format === 'mp3' ? 'mp3' : '',
|
|
||||||
'--no-playlist'
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if(mimeType?.includes('audio')) {
|
||||||
|
args.push(...['--extract-audio', '--embed-metadata', '--embed-thumbnail', '--audio-format', format])
|
||||||
|
} else if (mimeType.includes('video')) {
|
||||||
|
args.push(...['--embed-metadata', '--embed-thumbnail', '--format', format])
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`yt-dlp ${args.join(' ')} ${link}`)
|
||||||
|
|
||||||
const process = spawn('yt-dlp', [...args, link], { stdio: ['ignore', 'pipe', 'ignore'] });
|
const process = spawn('yt-dlp', [...args, link], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||||
|
|
||||||
process.stdout.on('data', (chunk) => controller.enqueue(chunk));
|
process.stdout.on('data', (chunk) => controller.enqueue(chunk));
|
||||||
|
|
|
@ -1,35 +1,80 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {PUBLIC_VERSION} from '$env/static/public';
|
import { PUBLIC_VERSION } from '$env/static/public';
|
||||||
|
import supportedFormats from '$lib/common/supportedFormats.json';
|
||||||
|
import Loader from '$lib/components/Loader.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let source = $state('youtube');
|
let source = $state('youtube');
|
||||||
let link = $state('');
|
let link = $state('');
|
||||||
let format = $state('mp3');
|
let format = $state('mp3');
|
||||||
let showModal = $state(false);
|
let showModal = $state(false);
|
||||||
let href = $state('');
|
let href = $state('');
|
||||||
// let formats = ['ogg', 'mp3', 'mp4']
|
let disabled = $state(true);
|
||||||
const formats = [{ value: 'mp3', label: 'MP3' }];
|
let metadata = $state(true);
|
||||||
|
let downloading = $state(false);
|
||||||
|
|
||||||
|
const formats = Object.keys(supportedFormats).map((f) => {
|
||||||
|
return { value: f, label: f.toUpperCase() };
|
||||||
|
});
|
||||||
|
|
||||||
const toggleModal = () => {
|
const toggleModal = () => {
|
||||||
showModal = !showModal;
|
showModal = !showModal;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent) => {
|
onMount(() => {
|
||||||
e.preventDefault();
|
document.cookie = 'downloading=0'
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
link = '';
|
||||||
|
downloading = true;
|
||||||
|
document.cookie = 'downloading=1'
|
||||||
|
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.cookie.includes('downloading=0')) {
|
||||||
|
console.log("FINITO!")
|
||||||
|
downloading = false && clearInterval(id);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAnchor = () => {
|
||||||
console.log({
|
console.log({
|
||||||
source,
|
source,
|
||||||
link,
|
link,
|
||||||
format
|
format,
|
||||||
|
metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!(source && link && format)) {
|
||||||
|
disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(link);
|
||||||
|
disabled = false;
|
||||||
|
} catch (err) {
|
||||||
|
/*
|
||||||
|
if (err.code === 'ERR_INVALID_URL') {
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
searchParams.append('source', source);
|
searchParams.append('source', source);
|
||||||
searchParams.append('link', link);
|
searchParams.append('link', link);
|
||||||
searchParams.append('format', format);
|
searchParams.append('format', format);
|
||||||
|
searchParams.append('metadata', metadata);
|
||||||
|
|
||||||
href = `/download?${searchParams.toString()}`;
|
href = `/download?${searchParams.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
createAnchor();
|
||||||
// Auto selected the radio button based on url regex
|
// Auto selected the radio button based on url regex
|
||||||
if (link.includes('spotify')) {
|
if (link.includes('spotify')) {
|
||||||
source = 'spotify';
|
source = 'spotify';
|
||||||
|
@ -43,6 +88,9 @@
|
||||||
id="wrapper"
|
id="wrapper"
|
||||||
class="relative mx-auto rounded-lg bg-black p-6 text-green-500 shadow-lg sm:max-w-sm sm:border-4 sm:border-green-500 md:mt-10 md:max-w-md lg:max-w-lg 2xl:max-w-2xl"
|
class="relative mx-auto rounded-lg bg-black p-6 text-green-500 shadow-lg sm:max-w-sm sm:border-4 sm:border-green-500 md:mt-10 md:max-w-md lg:max-w-lg 2xl:max-w-2xl"
|
||||||
>
|
>
|
||||||
|
<div id="loader" class={[{ downloading }]}>
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
<!-- Info Icon -->
|
<!-- Info Icon -->
|
||||||
<button
|
<button
|
||||||
onclick={toggleModal}
|
onclick={toggleModal}
|
||||||
|
@ -57,7 +105,7 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 id="title" class="mb-6 text-center text-xl">🐙 Scaricatore 🐙</h1>
|
<h1 id="title" class="mb-6 text-center text-xl">🐙 Scaricatore 🐙</h1>
|
||||||
<form class="space-y-6" onsubmit={handleSubmit}>
|
<form class="space-y-6">
|
||||||
<!-- Source Selection -->
|
<!-- Source Selection -->
|
||||||
<fieldset class="space-y-4">
|
<fieldset class="space-y-4">
|
||||||
<legend class="text-green-400">Choose Source:</legend>
|
<legend class="text-green-400">Choose Source:</legend>
|
||||||
|
@ -122,17 +170,28 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Format Selection -->
|
||||||
<button
|
<div>
|
||||||
type="submit"
|
<label for="metadata" class="mb-2 block text-green-400"> Metadata </label>
|
||||||
class="w-full rounded-lg border-4 border-pink-700 bg-pink-500 px-4 py-3 text-black transition hover:bg-pink-600 active:border-yellow-500"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
Create download link
|
id="metadata"
|
||||||
</button>
|
name="metadata"
|
||||||
|
bind:checked={metadata}
|
||||||
|
class="rounded-lg border-4 border-green-500 bg-green-200 px-4 py-3 text-black focus:border-pink-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if href}
|
<a
|
||||||
<a class="download-link" {href}> Download </a>
|
id="btn-download"
|
||||||
{/if}
|
{href}
|
||||||
|
onclick={onClick}
|
||||||
|
class="{disabled
|
||||||
|
? 'disabled'
|
||||||
|
: ''} block w-full rounded-lg border-4 border-pink-700 bg-pink-500 px-4 py-3 text-center text-4xl text-black transition hover:bg-pink-600 active:border-yellow-500"
|
||||||
|
>
|
||||||
|
DOWNLOAD
|
||||||
|
</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -167,12 +226,11 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-link {
|
#loader {
|
||||||
margin: 0 auto;
|
display: none;
|
||||||
padding: 5px;
|
}
|
||||||
|
#loader.downloading {
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: underline;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.not-available {
|
.not-available {
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
|
@ -203,4 +261,14 @@
|
||||||
#title {
|
#title {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-download {
|
||||||
|
font-size: 24px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
a.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getYouTubeMetadata, streamYouTube } from '$lib/server/ytdlp';
|
import { getYouTubeMetadata, streamYouTube, ytdl } from '$lib/server/ytdlp';
|
||||||
|
import { contentTypeFromFormat, mimeTypeMap } from '$lib/server/helpers';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
// Get query params
|
// Get query params
|
||||||
const link = url.searchParams.get('link');
|
const link = url.searchParams.get('link');
|
||||||
const format = url.searchParams.get('format'); // mp3, mp4
|
const format = url.searchParams.get('format'); // mp3, mp4
|
||||||
const source = url.searchParams.get('source'); // youtube or spotify
|
const source = url.searchParams.get('source'); // youtube or spotify
|
||||||
|
const metadata = url.searchParams.get('metadata');
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!link || !format || !source) {
|
if (!link || !format || !source) {
|
||||||
|
@ -18,22 +20,27 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch metadata for filename
|
let filename = `you-clicked-no-metadata-so-i-cant-put-a-correct-name.${format}`;
|
||||||
const metadata = await getYouTubeMetadata(link);
|
|
||||||
const { title, uploader } = metadata;
|
if (metadata) {
|
||||||
const safeTitle = `${uploader} - ${title}`;
|
// Fetch metadata for filename
|
||||||
const filename = `${safeTitle}.${format}`;
|
const metadata = await getYouTubeMetadata(link);
|
||||||
|
const { title, uploader } = metadata;
|
||||||
|
const safeTitle = `${uploader} - ${title}`;
|
||||||
|
filename = `${safeTitle}.${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(filename);
|
|
||||||
// Stream video/audio
|
// Stream video/audio
|
||||||
return new Response(streamYouTube(link, format), {
|
return new Response(streamYouTube(link, format), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': format === 'mp3' ? 'audio/mpeg' : 'video/mp4',
|
'Content-Type': contentTypeFromFormat(format),
|
||||||
'Content-Disposition': `attachment; filename="${filename}"`
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
'Set-Cookie': 'downloading=0'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching metadata:', err);
|
console.error(err)
|
||||||
|
console.error('Error fetching metadata:');
|
||||||
throw error(500, 'Failed to fetch video metadata');
|
throw error(500, 'Failed to fetch video metadata');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue