Compare commits

..

No commits in common. "main" and "tag-main" have entirely different histories.

35 changed files with 1769 additions and 2112 deletions

View file

@ -1,23 +0,0 @@
Dockerfile
.dockerignore
.git
.gitignore
.gitattributes
README.md
.npmrc
.prettierrc
.eslintrc.cjs
.graphqlrc
.editorconfig
.svelte-kit
.vscode
node_modules
build
package
**/.env
scripts/
downloads
yt-dlp
build.tar.gz

View file

@ -1,6 +0,0 @@
NODE_ENV=development
HOST=0.0.0.0
ORIGIN=https://dl.emersa.it
YTDLP_PATH=./yt-dlp
PUBLIC_VERSION=0.6.3
HTTPS_PROXY=

View file

@ -1,21 +0,0 @@
name: Bump deps (only minor versions)
on: [push]
jobs:
ci:
runs-on: docker
steps:
- name: Checkout Repository
uses: actions/checkout@v3
with:
token: ${{ secrets.FORGEJO_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Prepare, Check, Lint and Format
run: |
npm i
npm run prepare

View file

@ -2,7 +2,7 @@ name: Bump deps (only minor versions)
on:
schedule:
- cron: '0 23 * * *' # Runs every night at midnight
- cron: '0 0 * * *' # Runs every night at midnight (UTC)
workflow_dispatch:
jobs:
@ -26,7 +26,7 @@ jobs:
npm install
git config --global user.name "forgejo-bot"
git config --global user.email "bot@pweapon.org"
git add package* || exit 0
git add package.json || exit 0
git commit -m "chore: update minor dependencies"
git push origin HEAD:${GITHUB_REF#refs/heads/}
env:

View file

@ -2,8 +2,10 @@ name: Create Release
on:
push:
branches:
- main
tags:
- 'v*'
- "v*"
workflow_dispatch:
jobs:
@ -21,26 +23,13 @@ jobs:
with:
node-version: '20'
- name: Install JQ for extracting package.json version
- name: Prepare, Check, Lint and Format
run: |
apt-get update
apt-get install -y jq
- name: Create Release
run: |
npm ci
touch .env.production
echo "HOST=0.0.0.0" >> .env.production
echo "PORT=3000" >> .env.production
echo "ORIGIN=https://dl.emersa.it" >> .env.production
echo "PUBLIC_VERSION=$(cat package.json | jq .version)" >> .env.production
echo "NODE_ENV=production" >> .env.production
npm install
npm run build
cp package* build/
cp .env.production build/
mkdir releases
cd build/
tar czvf ../releases/build.tar.gz .
tar czvf releases/build.tar.gz build/
- name: Upload release
uses: actions/forgejo-release@v2

View file

@ -1,20 +0,0 @@
FROM node:20 AS build
WORKDIR /usr
COPY package.json ./
COPY package-lock.json ./
RUN npm ci
COPY . ./
RUN npm run build
FROM node:20
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm ci
COPY . ./
COPY --from=build /usr/build /app
EXPOSE 8000
CMD ["node", "/app"]

View file

@ -1,42 +1,20 @@
# scaricatore
## a frontend to yt-dlp
![version](https://git.pweapon.org/odo/dl.emersa.it/badges/release.svg 'version')
![status](https://git.pweapon.org/odo/dl.emersa.it/badges/workflows/ci.yaml/badge.svg 'status')
![GNU](https://img.shields.io/badge/license-GPL--3.0-green?logo=gnu)
It's a svelte(kit) frontend that uses `yt-dlp` to let the user download things from the web.
It's a svelte(kit) frontend + backend that uses [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) to let people download audio/video files from the web.
- `youtube-dl-exec`: to download video data
- `yt-dlp`: to stream the file the user requested
[`... we don't have the concept of users, when we refer to people, we call them people.`](https://ar.al/2020/08/07/what-is-the-small-web/)
### Getting started
<a href='https://dl.emersa.it'><img src='./static/screen.webp' alt='screen' height=300></a>
## Self-Hosting
### Node
- Install NodeJS
- Create a new directory (`"${PROJECT_ROOT}"`)
- run `scripts/install.sh "${PROJECT_ROOT}"`
Edit `scripts/deploy_example.sh` if you need to deploy `scaricatore` to some server using `SSH`.
### Docker
(currently not supported)
## Development: getting started
- run `git clone git@git.pweapon.org:odo/dl.emersa.it.git`
- Run `cd dl.emersa.it; npm install` (you have to have node installed)
- Run `npm run download-yt-dlp`
- Copy `.env.example` to `.env`
- Change `.env` to set `YTDLP_PATH` to the yt-dlp binary previously downloaded
- Clone the repo
- Run `npm install` (you have to have node installed)
- Run `npm run dev`
- Now you can write code
## To do:
### Missing
- Containerfile for container build
- Proper logs
- Containerfile
- Source spotify (spotdl)
- Parse URL info without `youtube-dl-exec`
- Dockerfile inside the forgejo release action

2944
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
{
"name": "dl.emersa.it",
"private": true,
"version": "1.0.0-c",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"download-yt-dlp": "wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O yt-dlp ; chmod +x yt-dlp",
"preview": "vite preview",
"download-yt-dlp": "wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O yt-dlp",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@ -15,34 +14,31 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.8",
"@eslint/js": "^9.25.0",
"@eslint/compat": "^1.2.6",
"@eslint/js": "^9.20.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.20.7",
"@sveltejs/kit": "^2.17.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.4",
"eslint": "^9.25.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-svelte": "^3.5.1",
"globals": "^16.0.0",
"mdsvex": "^0.12.3",
"prettier": "^3.5.3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.13.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0",
"prettier": "^3.5.1",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.28.1",
"svelte-check": "^4.1.6",
"sveltekit-sse": "^0.13.18",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.2"
"svelte": "^5.20.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.14.1",
"winston": "^3.17.0",
"youtube-dl-exec": "^3.0.20"
"youtube-dl-exec": "^3.0.15"
}
}

View file

@ -1,5 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
tailwindcss: {},
autoprefixer: {}
}
};

View file

@ -1,8 +1,11 @@
#!/bin/bash
## Prepares the current environment:
## - Installing dependencies
## - Configuring git hooks
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
set -e

View file

@ -1,23 +1,32 @@
#!/usr/bin/bash
# This is a script for running `install.sh`
# in a remote server using SSH.
# Configure SSH_SERVER and PROJECT_ROOT variables
# for using this script.
###### ATTENTION ######
# The install.sh script has a confirmation prompt
# because it deletes the content of the PROJECT_ROOT folder,
# just put "yes | install.sh" to automatically confirm.
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NO_DELETE=false
SSH_SERVER="example"
PROJECT_ROOT=example_directory
SSH_REMOTE_DIR="${SSH_SERVER}:${PROJECT_ROOT}"
ssh "${SSH_SERVER}" "mkdir -p ${PROJECT_ROOT}"
rsync "${__dir}/install.sh" "${SSH_SERVER}:${PROJECT_ROOT}/install.sh"
ssh "${SSH_SERVER}" "${PROJECT_ROOT}/install.sh ${PROJECT_ROOT}"
ssh "${SSH_SERVER}" "systemctl restart downloader"
for arg in "$@"
do
if [ "$arg" == "--no-delete" ]; then
NO_DELETE=true
fi
done
if [ "$NO_DELETE" = false ]; then
echo "Deleting node_modules..."
rm -rf node_modules/
else
echo "Skipping deletion of node_modules."
fi
npm ci
npm run build
rsync -r --delete --progress build/ "${SSH_REMOTE_DIR}"
rsync package.json "${SSH_REMOTE_DIR}"
rsync package-lock.json "${SSH_REMOTE_DIR}"
ssh "${SSH_SERVER}" "cd ${PROJECT_ROOT}; npm ci"
ssh "${SSH_SERVER}" "systemctl restart downloader"

View file

@ -1,33 +0,0 @@
#!/usr/bin/bash
### This is a script for installing the latest release
### of `scaricatore`.
PROJECT_ROOT=$1
PROXY=$2
if [ -z ${PROJECT_ROOT+x} ]; then
echo "ERROR: No PROJECT_ROOT set (first argument to ${0})"
exit 1
fi
if [ -z ${PROXY+x} ]; then
echo "INFO: no proxy given"
fi
mkdir -p "${PROJECT_ROOT}"
cd "${PROJECT_ROOT}" || exit 1
read -p "We're about to run rm -rf ${PROJECT_ROOT}/*. Are you sure?" -n 1 -r
if [[ $REPLY =~ ^[Yy]$ ]]
then
rm -rf ./*
fi
wget https://git.pweapon.org/odo/dl.emersa.it/releases/download/latest/build.tar.gz
tar -xvf build.tar.gz -C .
rm build.tar.gz
npm ci
npm run download-yt-dlp
echo "YTDLP_PATH=$(readlink -f yt-dlp)" >> .env.production
echo "HTTPS_PROXY=${PROXY}" >> .env.production

View file

@ -1,17 +0,0 @@
#!/bin/bash
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
set -e
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
version="${1:?Error: No argument provided. Usage: $0 <version>}"
npm version "v${version}"
git push --tags

3
scripts/src/pre-commit.sh Executable file → Normal file
View file

@ -9,4 +9,7 @@
set -e
npm run prepare
npm run check
npm run format
npm run lint

View file

@ -1,22 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

View file

@ -7,11 +7,28 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<style>
* {
font-family: 'Fira Code', monospace;
@font-face {
font-family: 'Press Start 2P';
src: url('/fonts/PressStart2P-Regular.ttf');
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body {
background: black; /* Fallback */
/* background-image:
radial-gradient(circle, #37ff1456 8%, transparent 8%),
radial-gradient(circle, #37ff1456 8%, transparent 8%); */
background-size:
60px 60px,
30px 30px; /* Larger grid sizes for more spacing */
background-position:
0 0,
15px 15px; /* Offset the smaller pattern slightly */
font-family: 'Press Start 2P', sans-serif;
color: #37ff1456; /* Retro green text */
margin: 0;
}

View file

@ -1,7 +0,0 @@
import { logger } from '$lib/server/helpers';
export async function handle({ event, resolve }) {
logger.info(`Received ${event.request.method} request: ${event.url}`);
return await resolve(event);
}

View file

@ -1,21 +0,0 @@
const createAnchorElement = (url: string, filename: string): HTMLAnchorElement => {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
return anchor;
};
export const download = async (url: string, filename: string) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const blob = await response.blob();
const objectURL = window.URL.createObjectURL(blob);
const anchor = createAnchorElement(url, filename);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(objectURL);
};

View file

@ -1,6 +0,0 @@
{
"mp3": "audio/mpeg",
"opus": "audio/ogg",
"wav": "audio/wav",
"mp4": "video/mp4"
}

View file

@ -1,73 +1,3 @@
<script lang="ts">
import { onMount } from 'svelte';
let { url, dismiss } = $props();
let visible = $state(false);
let progress = $state(0);
let filename = $state('');
onMount(async () => {
if (!url) return;
try {
visible = true;
const response = await fetch(url);
if (!response.ok) throw new Error('Download failed');
const contentDisposition: string = response?.headers?.get('content-disposition');
filename = contentDisposition.split('filename=')[1];
const contentLength: number = Number(response?.headers?.get('content-length'));
const reader = response?.body?.getReader();
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value }: ReadableStreamReadResult<Uint8Array> = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
receivedLength += value.length;
progress = Math.round((receivedLength / contentLength) * 100);
}
}
const blob = new Blob(chunks);
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(downloadUrl);
setTimeout(() => {
visible = false;
}, 1500); // auto-dismiss
} catch (err) {
console.error(err);
visible = false;
}
});
let props = $props();
</script>
{#if visible}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 font-mono backdrop-blur-sm"
>
<div
class="w-[90%] max-w-sm rounded-2xl border border-green-400 bg-[#000f00] p-6 text-sm text-green-300 shadow-2xl"
>
<p class="mb-2 text-center text-cyan-300">
Downloading <span class="font-semibold text-green-400">{filename}</span>
</p>
<div class="h-4 w-full overflow-hidden rounded-md border border-green-500 bg-black">
<div
class="h-full bg-gradient-to-r from-green-400 to-green-600 transition-all duration-300"
style="width: {progress}%"
/>
</div>
<p class="mt-2 text-center text-pink-400">{progress}%</p>
</div>
</div>
{/if}

View file

@ -1,28 +1,6 @@
<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
class="absolute inset-0 z-10 flex items-center justify-center bg-white bg-opacity-50"
id="spinner"
>
<div class="h-20 w-20 animate-spin rounded-full border-b-2 border-t-2 border-gray-900"></div>
</div>

View file

@ -1,19 +0,0 @@
import formats from '$lib/common/supportedFormats.json';
import winston from 'winston';
export const logger = winston.createLogger({
level: 'debug',
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
const formatMime = new Map(Object.entries(formats));
export const isURLValid = (url: string) => {
try {
new URL(url);
} catch {
return false;
}
return true;
};
export const mimeTypeMap = formatMime;

View file

@ -1,11 +1,7 @@
import { create } from 'youtube-dl-exec';
import { env } from '$env/dynamic/private';
import { spawn } from 'node:child_process';
import supportedFormats from '$lib/common/supportedFormats.json';
import { logger, mimeTypeMap } from '$lib/server/helpers';
const YTDLP_PATH: string = env.YTDLP_PATH as string;
const HTTPS_PROXY: string = env.HTTPS_PROXY as string;
const YTDLP_PATH: string = env.YTDLP_PATH as string
export const ytdl = create(YTDLP_PATH);
@ -13,73 +9,37 @@ export const ytdl = create(YTDLP_PATH);
* Fetch YouTube metadata (title, uploader/artist)
*/
export async function getYouTubeMetadata(link: string) {
const options = {
return await ytdl(link, {
dumpSingleJson: true,
noCheckCertificates: true,
noWarnings: true,
preferFreeFormats: true
};
if (HTTPS_PROXY) {
options.proxy = HTTPS_PROXY;
}
return await ytdl(link, options);
});
}
/**
* Streams the YouTube video/audio using youtube-dl-exec
*/
export function streamYouTube(link: string, format: string): ReadableStream<Uint8Array> {
logger.debug(`Starting to stream: ${link}`);
const mimeType: string | undefined = mimeTypeMap.get(format);
if (!mimeType) {
throw new Error('Unsupported format');
}
logger.debug(`Given format is compatible: ${mimeType}`);
return new ReadableStream({
start(controller) {
const args = ['--no-write-thumbnail', '-o', '-'];
const args = [
'-o',
'-',
format === 'mp3' ? '--embed-metadata' : '',
'--format',
format === 'mp3' ? 'bestaudio' : 'best',
'--audio-format',
format === 'mp3' ? 'mp3' : '',
'--no-playlist'
].filter(Boolean);
if (HTTPS_PROXY) {
args.push('--proxy', HTTPS_PROXY);
}
const process = spawn('yt-dlp', [...args, link], { stdio: ['ignore', 'pipe', 'ignore'] });
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]);
}
const cmd = `${YTDLP_PATH} ${args.join(' ')} ${link}`;
logger.debug(`Running: ${cmd}`);
const process = spawn(YTDLP_PATH, [...args, link], {
cwd: '/tmp',
stdio: ['ignore', 'pipe', 'pipe']
});
process.stdout.on('data', (chunk) => {
try {
controller.enqueue(chunk);
} catch (ex) {
process.kill();
}
});
process.stderr.on('data', (chunk) => logger.debug(chunk.toString()));
process.stdout.on('end', () => {
try {
controller.close();
} catch (ex) {
logger.error(ex);
}
});
process.stdout.on('data', (chunk) => controller.enqueue(chunk));
process.stdout.on('end', () => controller.close());
process.stdout.on('error', (err) => {
logger.error('Stream error:', err);
console.error('Stream error:', err);
controller.error(err);
});
}

View file

@ -17,11 +17,4 @@
transform: rotate(180deg);
display: inline-block;
}
@media screen and (max-height: 600px) {
/* Your CSS rules here */
footer {
display: none;
}
}
</style>

View file

@ -1,3 +0,0 @@
export const prerender = true;
export const ssr = true;
export const csr = true;

View file

@ -1,77 +1,34 @@
<script lang="ts">
import { PUBLIC_VERSION } from '$env/static/public';
import supportedFormats from '$lib/common/supportedFormats.json';
import Loader from '$lib/components/Loader.svelte';
import { download } from '$lib/client/downloader';
import DownloadManager from '$lib/components/DownloadManager.svelte';
import { mount, unmount } from 'svelte';
<script>
let source = $state('youtube');
let link = $state('');
let format = $state('mp3');
let showModal = $state(false);
let href = $state('');
let disabled = $state(true);
let metadata = $state(false);
let logs = $state('');
let downloadManager: DownloadManager | null = null;
const formats = Object.keys(supportedFormats).map((f) => {
return { value: f, label: f.toUpperCase() };
});
const sources = [
{ value: 'youtube', label: 'YouTube' },
{ value: 'youtube', label: 'Any Other Website' }
];
// let formats = ['ogg', 'mp3', 'mp4']
const formats = [{ value: 'mp3', label: 'MP3' }];
const toggleModal = () => {
showModal = !showModal;
};
const dismiss = () => {
unmount(downloadManager);
};
const handleSubmit = async (e) => {
e.preventDefault();
const onClick = async (evt) => {
evt.preventDefault();
const props = $state({ url: href });
downloadManager = mount(DownloadManager, { target: document.body, props, events: { dismiss } });
link = '';
};
const createAnchor = () => {
if (!(source && link && format)) {
disabled = true;
return;
}
try {
new URL(link);
disabled = false;
} catch (err) {
/*
if (err.code === 'ERR_INVALID_URL') {
}
*/
disabled = true;
return;
}
console.log({
source,
link,
format
});
const searchParams = new URLSearchParams();
searchParams.append('source', source);
searchParams.append('link', link);
searchParams.append('format', format);
if (metadata) searchParams.append('metadata', '1');
href = `/download?${searchParams.toString()}`;
};
$effect(() => {
createAnchor();
// Auto selected the radio button based on url regex
if (link.includes('spotify')) {
source = 'spotify';
@ -83,130 +40,166 @@
<div
id="wrapper"
class="relative mx-auto max-w-full rounded-2xl bg-black p-4 text-green-400 shadow-xl sm:max-w-sm md:max-w-md lg:max-w-lg"
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"
>
<!-- Loader Overlay -->
<div
id="loader"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/80 backdrop-blur-sm"
>
<Loader />
{@html logs}
</div>
<!-- Info Button -->
<!-- Info Icon -->
<button
onclick={toggleModal}
class="absolute top-3 right-3 text-pink-500 transition hover:text-pink-400"
class="absolute right-3 top-3 text-pink-500 transition hover:text-pink-300"
aria-label="Open Info Modal"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="h-5 w-5" viewBox="0 0 24 24">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="h-6 w-6" viewBox="0 0 24 24">
<path
d="M12 0C5.373 0 0 5.373 0 12c0 6.627 5.373 12 12 12s12-5.373 12-12C24 5.373 18.627 0 12 0zm.75 18h-1.5v-6h1.5v6zm0-8h-1.5V8h1.5v2z"
/>
</svg>
</button>
<!-- Title -->
<p id="title" class="mb-4 text-center font-mono text-xl tracking-tight text-cyan-300">
🐙 Scaricatore 🐙
</p>
<h1 id="title" class="mb-6 text-center text-xl">🐙 Scaricatore 🐙</h1>
<form class="space-y-6" onsubmit={handleSubmit}>
<!-- Source Selection -->
<fieldset class="space-y-4">
<legend class="text-green-400">Choose Source:</legend>
<!-- Form -->
<form class="space-y-5 font-mono text-sm">
<!-- Source & Format -->
<div class="flex flex-col gap-4 sm:flex-row">
<div class="flex-1">
<label for="source" class="mb-1 block text-cyan-300">Source</label>
<select
id="source"
<label class="flex items-center space-x-3">
<input type="radio" name="source" value="youtube" bind:group={source} class="retro-radio" />
<span>YouTube</span>
</label>
<label class="flex items-center space-x-3">
<input
disabled
type="radio"
name="source"
bind:value={source}
class="w-full rounded-md border border-green-400 bg-[#000f00] px-3 py-2 text-green-300 focus:border-pink-400 focus:outline-none"
>
{#each sources as source}
<option value={source.value}>{source.label}</option>
{/each}
</select>
</div>
value="spotify"
bind:group={source}
class="retro-radio"
/>
<span class="not-available">Spotify</span>
</label>
<div class="flex-1">
<label for="format" class="mb-1 block text-cyan-300">Format</label>
<select
id="format"
name="format"
bind:value={format}
class="w-full rounded-md border border-green-400 bg-[#000f00] px-3 py-2 text-green-300 focus:border-pink-400 focus:outline-none"
>
{#each formats as format}
<option value={format.value}>{format.label}</option>
{/each}
</select>
</div>
</div>
<label class="flex items-center space-x-3">
<input type="radio" name="source" value="other" bind:group={source} class="retro-radio" />
<span>
Other (<a
href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md"
target="_blank"
rel="noopener noreferrer"
class="text-pink-500 hover:underline"
>supported sites
</a>)
</span>
</label>
</fieldset>
<!-- Video Link -->
<!-- Link Input -->
<div>
<label for="link" class="mb-1 block text-cyan-300">Video Link</label>
<label for="link" class="mb-2 block text-green-400"> Enter Playlist or Video Link: </label>
<input
name="link"
type="url"
id="link"
name="link"
bind:value={link}
required
placeholder="https://..."
class="w-full rounded-md border border-green-400 bg-[#000f00] px-3 py-2 text-green-300 placeholder:text-green-600 focus:border-pink-400 focus:outline-none"
placeholder="Paste your link here"
class="w-full rounded-lg border-4 border-green-500 bg-green-200 px-4 py-3 text-black focus:border-pink-500 focus:outline-none"
/>
</div>
<!-- Metadata Checkbox -->
<div class="flex items-center gap-2">
<input
type="checkbox"
id="metadata"
name="metadata"
bind:checked={metadata}
class="h-4 w-4 border border-green-400 bg-[#000f00] text-green-500 focus:ring-pink-400"
/>
<label for="metadata" class="text-cyan-300"
>Set filename (<span class="text-red-500">slow</span>)</label
<!-- Format Selection -->
<div>
<label for="format" class="mb-2 block text-green-400"> Choose Format: </label>
<select
id="format"
name="format"
bind:value={format}
class="w-full rounded-lg border-4 border-green-500 bg-green-200 px-4 py-3 text-black focus:border-pink-500 focus:outline-none"
>
{#each formats as format}
<option value={format.value}>{format.label}</option>
{/each}
</select>
</div>
<!-- Download Button -->
<a
id="btn-download"
{href}
onclick={onClick}
class="{disabled
? 'pointer-events-none opacity-50'
: ''} block w-full rounded-md border border-pink-400 bg-pink-600 px-4 py-3 text-center text-base font-bold text-black transition hover:bg-pink-500 active:border-yellow-400"
<!-- Submit Button -->
<button
type="submit"
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"
>
DOWNLOAD
</a>
Create download link
</button>
{#if href}
<a class="download-link" {href}> Download </a>
{/if}
</form>
</div>
<!-- Modal -->
{#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 text-green-300">
<div class="w-[90%] max-w-md rounded-lg border border-green-400 bg-[#001a00] p-5 text-center">
<h2 class="mb-3 text-base font-semibold">🐙 Scaricatore v{PUBLIC_VERSION} 🐙</h2>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80">
<div
class="w-4/5 max-w-lg rounded-lg border-4 border-green-500 bg-green-900 p-6 text-center text-green-100"
>
<h2 class="mb-4 text-lg">🐙 Scaricatore 🐙</h2>
<p>
Download Spotify playlists and YouTube videos with ease. Choose your source, paste a link,
select format, go!
This app allows you to download Spotify playlists and YouTube videos directly. Choose your
source, paste the link, and select a format to start downloading!
</p>
<p class="mt-3">
<a class="text-cyan-400 underline" href="https://git.pweapon.org/odo/dl.emersa.it"
>Source Code</a
<span class="mt-10 block">
<a class="underline" href="https://git.pweapon.org/odo/dl.emersa.it"
>Click here for the source code</a
>
</p>
</span>
<button
onclick={toggleModal}
class="mt-6 rounded-md border border-pink-400 bg-pink-600 px-4 py-2 text-black hover:bg-pink-500"
class="mt-6 rounded-lg border-4 border-pink-700 bg-pink-500 px-4 py-2 text-black hover:bg-pink-600"
>
Close
</button>
</div>
</div>
{/if}
<style>
* {
font-size: 12px;
}
.download-link {
margin: 0 auto;
padding: 5px;
display: block;
text-decoration: underline;
text-align: center;
}
.not-available {
text-decoration-line: line-through;
text-decoration-color: red;
}
.retro-radio {
appearance: none;
background-color: #000;
border: 2px solid #39ff14;
width: 20px;
height: 20px;
cursor: pointer;
}
.retro-radio:checked {
background-color: #39ff14;
box-shadow:
0 0 4px #39ff14,
0 0 10px #39ff14;
}
input[type='url'],
select {
font-family: inherit;
}
#title {
font-size: 22px;
}
</style>

View file

@ -1,14 +1,12 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getYouTubeMetadata, streamYouTube, ytdl } from '$lib/server/ytdlp';
import { isURLValid, logger, mimeTypeMap } from '$lib/server/helpers';
import { getYouTubeMetadata, streamYouTube } from '$lib/server/ytdlp';
const validateRequest = (url: URL) => {
export const GET: RequestHandler = async ({ url }) => {
// Get query params
const link = url.searchParams.get('link');
const format = url.searchParams.get('format'); // mp3, mp4
const source = url.searchParams.get('source'); // youtube or spotify
const metadata = url.searchParams.has('metadata');
// Validate input
if (!link || !format || !source) {
@ -19,56 +17,23 @@ const validateRequest = (url: URL) => {
throw error(400, 'Currently, only YouTube is supported');
}
if (!isURLValid(link)) {
throw error(400, 'URL not valid');
}
if (!mimeTypeMap.get(format)) {
throw error(400, 'format not valid');
}
logger.debug(`Request is valid`);
return {
link,
format,
source,
metadata
};
};
export const GET: RequestHandler = async ({ url }) => {
const { format, source, metadata, link } = validateRequest(url);
let filename = '';
let contentLength = 0;
logger.debug(`Requested: ${link}`);
try {
logger.debug(`Fetching video data to set filename`);
// Fetch metadata for filename
const ytMetadata = await getYouTubeMetadata(link);
const { title, uploader, filesize_approx } = ytMetadata;
contentLength = filesize_approx;
const metadata = await getYouTubeMetadata(link);
const { title, uploader } = metadata;
const safeTitle = `${uploader} - ${title}`;
filename = `${safeTitle}.${format}`;
} catch (err) {
logger.error(err);
logger.error('Error fetching metadata:');
throw error(500, 'Failed to fetch video metadata');
}
const filename = `${safeTitle}.${format}`;
try {
console.log(filename);
// Stream video/audio
return new Response(streamYouTube(link, format), {
headers: {
'Content-Type': `${mimeTypeMap.get(format)}`,
'Content-Length': contentLength,
'Content-Type': format === 'mp3' ? 'audio/mpeg' : 'video/mp4',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (err) {
logger.error(err);
logger.error('Filed to stream file');
throw error(500, 'Failed to stream file');
console.error('Error fetching metadata:', err);
throw error(500, 'Failed to fetch video metadata');
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

@ -1,16 +1,16 @@
{
"name": "scaricatore",
"name": "EmersaDownloader",
"start_url": "https://dl.emersa.it",
"theme_color": "rgb(34,197,94)",
"background": "black",
"orientation": "portrait",
"display": "fullscreen",
"short_name": "scaricatore",
"short_name": "e-downloader",
"icons": [
{
"src": "favicon.png",
"type": "image/png",
"sizes": "512x512"
"sizes": "128x128"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,4 +1,3 @@
import { mdsvex } from 'mdsvex';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
@ -6,13 +5,14 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: [vitePreprocess(), mdsvex()],
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
},
extensions: ['.svelte', '.svx']
}
};
export default config;

View file

@ -5,9 +5,9 @@ After=network.target
[Service]
User=user
Group=user
WorkingDirectory=/home/user/downloader
EnvironmentFile=/home/user/downloader/.env
ExecStart=/usr/bin/node /home/user/downloader
WorkingDirectory=<PROJECT_ROOT>
ExecStart=/usr/bin/node <PROJECT_ROOT>
Environment=ORIGIN=http://example.com
Restart=always
RestartSec=10
StandardOutput=syslog
@ -16,4 +16,3 @@ SyslogIdentifier=downloader
[Install]
WantedBy=multi-user.target

13
tailwind.config.ts Normal file
View file

@ -0,0 +1,13 @@
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [typography, forms]
} satisfies Config;

View file

@ -1,7 +1,6 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()]
plugins: [sveltekit()]
});