Compare commits

..

No commits in common. "main" and "v0.6.0" have entirely different histories.
main ... v0.6.0

33 changed files with 1836 additions and 2230 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,5 +1,3 @@
HOST=0.0.0.0
ORIGIN=https://dl.emersa.it
YTDLP_PATH=./yt-dlp YTDLP_PATH=./yt-dlp
PUBLIC_VERSION=0.6.3 ORIGIN=http://example.com
HTTPS_PROXY= HOST=0.0.0.0

View file

@ -1,4 +1,4 @@
name: Just some continuos integration name: Bump deps (only minor versions)
on: [push] on: [push]
@ -15,16 +15,8 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '20' node-version: '20'
- name: Format things if needed - name: Prepare, Check, Lint and Format
run: | run: |
npm install npm install
npm run prepare npm run prepare
npm run lint npm run check
npm run format
git config --global user.name "forgejo-bot"
git config --global user.email "bot@pweapon.org"
git add --all || exit 0
git commit -m "chore: lint and format" || exit 0
git push origin HEAD:${GITHUB_REF#refs/heads/}
env:
GITHUB_TOKEN: ${{ secrets.FORGEJO_TOKEN }}

View file

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

View file

@ -21,26 +21,14 @@ jobs:
with: with:
node-version: '20' node-version: '20'
- name: Install JQ for extracting package.json version - name: Prepare, Check, Lint and Format
run: | run: |
apt-get update npm install
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 run build npm run build
cp .env.example build/.env
cp package* build/ cp package* build/
cp .env.production build/
mkdir releases mkdir releases
cd build/ tar czvf releases/build.tar.gz build/
tar czvf ../releases/build.tar.gz .
- name: Upload release - name: Upload release
uses: actions/forgejo-release@v2 uses: actions/forgejo-release@v2

View file

@ -2,21 +2,22 @@
![version](https://git.pweapon.org/odo/dl.emersa.it/badges/release.svg 'version') ![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') ![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 + backend that uses [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) to let people download audio/video files from the web. <img src='./static/screen.webp' alt='screen' height=300>
<a href='https://dl.emersa.it'><img src='./static/screen.webp' alt='screen' height=300></a> It's a svelte(kit) frontend that uses `yt-dlp` to let the user download things from the web.
## Self-Hosting ## How to Deploy
### Node ### Node
- Install NodeJS 0. Install nodejs
- Create a new directory (`"${PROJECT_ROOT}"`) 1. Download the latest release from the [releases](https://git.pweapon.org/odo/dl.emersa.it/releases) page.
- run `scripts/install.sh "${PROJECT_ROOT}"` 2. Unpack and decompress (`tar xvf build.tar.gz`) the release
3. Then `cd` into it and run: `npm ci` and `npm run build`
Edit `scripts/deploy_example.sh` if you need to deploy `scaricatore` to some server using `SSH`. 4. Copy `.env.example` to `.env`, and add needed variables
5. Run it: `node .`
6. You can also try to configure the systemd unit file inside the `configurations` folder.
### Docker ### Docker
@ -24,8 +25,8 @@ Edit `scripts/deploy_example.sh` if you need to deploy `scaricatore` to some ser
## Development: getting started ## Development: getting started
- run `git clone git@git.pweapon.org:odo/dl.emersa.it.git` - Clone the repo
- Run `cd dl.emersa.it; npm install` (you have to have node installed) - Run `npm install` (you have to have node installed)
- Run `npm run download-yt-dlp` - Run `npm run download-yt-dlp`
- Copy `.env.example` to `.env` - Copy `.env.example` to `.env`
- Change `.env` to set `YTDLP_PATH` to the yt-dlp binary previously downloaded - Change `.env` to set `YTDLP_PATH` to the yt-dlp binary previously downloaded
@ -34,7 +35,7 @@ Edit `scripts/deploy_example.sh` if you need to deploy `scaricatore` to some ser
## To do: ## To do:
- Proper logs (I don't like them, not useful for production build)
- Containerfile for container build - Containerfile for container build
- Source spotify (spotdl) - Source spotify (spotdl)
- Parse URL info without `youtube-dl-exec` - Parse URL info without `youtube-dl-exec`
- Dockerfile inside the forgejo release action

View file

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

3260
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
{ {
"name": "dl.emersa.it", "name": "dl.emersa.it",
"private": true, "private": true,
"version": "1.0.2-b", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "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", "download-yt-dlp": "wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O yt-dlp",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@ -15,34 +14,31 @@
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.1", "@eslint/compat": "^1.2.6",
"@eslint/js": "^9.31.0", "@eslint/js": "^9.20.0",
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.22.5", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/kit": "^2.17.1",
"@tailwindcss/vite": "^4.1.11", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"eslint": "^9.31.0", "@tailwindcss/forms": "^0.5.10",
"eslint-config-prettier": "^10.1.5", "@tailwindcss/typography": "^0.5.16",
"eslint-plugin-svelte": "^3.10.1", "@types/node": "^22.13.4",
"globals": "^16.3.0", "autoprefixer": "^10.4.20",
"mdsvex": "^0.12.6", "eslint": "^9.20.1",
"prettier": "^3.6.2", "eslint-config-prettier": "^10.0.1",
"prettier-plugin-svelte": "^3.4.0", "eslint-plugin-svelte": "^2.46.1",
"prettier-plugin-tailwindcss": "^0.6.14", "globals": "^15.15.0",
"svelte": "^5.35.6", "prettier": "^3.5.1",
"svelte-check": "^4.2.2", "prettier-plugin-svelte": "^3.3.3",
"sveltekit-sse": "^0.14.1", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.1.11", "svelte": "^5.20.0",
"typescript": "^5.8.3", "svelte-check": "^4.1.4",
"typescript-eslint": "^8.36.0", "tailwindcss": "^3.4.17",
"vite": "^6.3.5" "typescript": "^5.7.3",
"typescript-eslint": "^8.24.0",
"vite": "^6.1.0"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/forms": "^0.5.10", "youtube-dl-exec": "^3.0.15"
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^24.0.13",
"winston": "^3.17.0",
"youtube-dl-exec": "^3.0.22"
} }
} }

View file

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

View file

@ -1,8 +1,11 @@
#!/bin/bash #!/bin/bash
#
## Prepares the current environment: # An example hook script to verify what is about to be committed.
## - Installing dependencies # Called by "git commit" with no arguments. The hook should
## - Configuring git hooks # 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 set -e

View file

@ -1,23 +1,32 @@
#!/usr/bin/bash #!/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)" __dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NO_DELETE=false
SSH_SERVER="example" SSH_SERVER="example"
PROJECT_ROOT=example_directory PROJECT_ROOT=example_directory
SSH_REMOTE_DIR="${SSH_SERVER}:${PROJECT_ROOT}" SSH_REMOTE_DIR="${SSH_SERVER}:${PROJECT_ROOT}"
ssh "${SSH_SERVER}" "mkdir -p ${PROJECT_ROOT}" for arg in "$@"
rsync "${__dir}/install.sh" "${SSH_SERVER}:${PROJECT_ROOT}/install.sh" do
ssh "${SSH_SERVER}" "${PROJECT_ROOT}/install.sh ${PROJECT_ROOT}" 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" 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

@ -15,4 +15,3 @@ version="${1:?Error: No argument provided. Usage: $0 <version>}"
npm version "v${version}" npm version "v${version}"
git push --tags git push --tags
git push

View file

@ -1,22 +1,3 @@
@import 'tailwindcss'; @import 'tailwindcss/base';
@import 'tailwindcss/components';
@plugin '@tailwindcss/typography'; @import 'tailwindcss/utilities';
@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);
}
}

View file

@ -7,11 +7,28 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<style> <style>
* { @font-face {
font-family: 'Fira Code', monospace; font-family: 'Press Start 2P';
src: url('/fonts/PressStart2P-Regular.ttf');
} }
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body { body {
background: black; /* Fallback */ 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 */ color: #37ff1456; /* Retro green text */
margin: 0; 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,6 +0,0 @@
{
"mp3": "audio/mpeg",
"opus": "audio/ogg",
"wav": "audio/wav",
"mp4": "video/mp4"
}

View file

@ -0,0 +1,3 @@
<script lang="ts">
let props = $props();
</script>

View file

@ -0,0 +1,6 @@
<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,22 +0,0 @@
<script lang="ts">
let { progress, filename } = $props();
</script>
<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>
</div>
<p class="mt-2 text-center text-pink-400">{progress}%</p>
</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,10 +1,7 @@
import { create, type Flags } 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 { logger, mimeTypeMap } from '$lib/server/helpers';
const YTDLP_PATH: string = env.YTDLP_PATH as string; const YTDLP_PATH: string = env.YTDLP_PATH as string;
const HTTPS_PROXY: string = env.HTTPS_PROXY as string;
export const ytdl = create(YTDLP_PATH); export const ytdl = create(YTDLP_PATH);
@ -12,74 +9,37 @@ export const ytdl = create(YTDLP_PATH);
* Fetch YouTube metadata (title, uploader/artist) * Fetch YouTube metadata (title, uploader/artist)
*/ */
export async function getYouTubeMetadata(link: string) { export async function getYouTubeMetadata(link: string) {
const options: Flags = { return await ytdl(link, {
dumpSingleJson: true, dumpSingleJson: true,
noCheckCertificates: true, noCheckCertificates: true,
noWarnings: true, noWarnings: true,
preferFreeFormats: true preferFreeFormats: true
}; });
if (HTTPS_PROXY) {
options.proxy = HTTPS_PROXY;
}
return await ytdl(link, options);
} }
/** /**
* 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> {
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({ return new ReadableStream({
start(controller) { 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) { const process = spawn('yt-dlp', [...args, link], { stdio: ['ignore', 'pipe', 'ignore'] });
args.push('--proxy', HTTPS_PROXY);
}
if (mimeType?.includes('audio')) { process.stdout.on('data', (chunk) => controller.enqueue(chunk));
args.push( process.stdout.on('end', () => controller.close());
...['--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 {
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('error', (err) => { process.stdout.on('error', (err) => {
logger.error('Stream error:', err); console.error('Stream error:', err);
controller.error(err); controller.error(err);
}); });
} }

View file

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

View file

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

View file

@ -1,95 +1,24 @@
<script lang="ts"> <script lang="ts">
import { PUBLIC_VERSION } from '$env/static/public';
import supportedFormats from '$lib/common/supportedFormats.json';
import DownloadManager from '$lib/components/ProgressBar.svelte';
let source = $state('youtube'); let source = $state('youtube');
let link = $state(''); let link = $state('');
let downloading = $state(false);
let format = $state('mp3'); let format = $state('mp3');
let showModal = $state(false); let showModal = $state(false);
let href = $state(''); let href = $state('');
let disabled = $state(true); // let formats = ['ogg', 'mp3', 'mp4']
let progress = $state(0); const formats = [{ value: 'mp3', label: 'MP3' }];
let filename = $state('');
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' }
];
const toggleModal = () => { const toggleModal = () => {
showModal = !showModal; showModal = !showModal;
}; };
const download = async (url: string) => { const handleSubmit = async (e: SubmitEvent) => {
const response = await fetch(url); e.preventDefault();
if (!response.ok) { console.log({
throw new Error('Network response was not ok'); source,
} link,
format
const contentDisposition: string | null = response?.headers?.get('content-disposition'); });
filename = contentDisposition?.split('filename=')[1] || 'noname';
// 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 } = await reader!.read();
if (done) break;
if (value) {
chunks.push(value);
receivedLength += value.length;
progress = Math.round((receivedLength / 50000) * 100);
}
}
const blob = new Blob(chunks);
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
a.click();
window.URL.revokeObjectURL(downloadUrl);
};
const onClick = async (evt) => {
evt.preventDefault();
downloading = true;
await download(href);
downloading = false;
link = '';
progress = 0;
filename = '';
};
const createAnchor = () => {
if (!(source && link && format)) {
disabled = true;
return;
}
try {
new URL(link);
disabled = false;
} catch {
/*
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);
@ -100,7 +29,6 @@
}; };
$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';
@ -112,111 +40,166 @@
<div <div
id="wrapper" 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"
> >
<!-- Info Button --> <!-- Info Icon -->
<button <button
onclick={toggleModal} 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" 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 <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" 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> </svg>
</button> </button>
<!-- Title --> <h1 id="title" class="mb-6 text-center text-xl">🐙 Scaricatore 🐙</h1>
<p id="title" class="mb-4 text-center font-mono text-xl tracking-tight text-cyan-300"> <form class="space-y-6" onsubmit={handleSubmit}>
🐙 Scaricatore 🐙 <!-- Source Selection -->
</p> <fieldset class="space-y-4">
<legend class="text-green-400">Choose Source:</legend>
<!-- Form --> <label class="flex items-center space-x-3">
<form class="space-y-5 font-mono text-sm"> <input type="radio" name="source" value="youtube" bind:group={source} class="retro-radio" />
<!-- Source & Format --> <span>YouTube</span>
<div class="flex flex-col gap-4 sm:flex-row"> </label>
<div class="flex-1">
<label for="source" class="mb-1 block text-cyan-300">Source</label> <label class="flex items-center space-x-3">
<select <input
id="source" disabled
type="radio"
name="source" name="source"
bind:value={source} value="spotify"
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" bind:group={source}
> class="retro-radio"
{#each sources as source (source.label)} />
<option value={source.value}>{source.label}</option> <span class="not-available">Spotify</span>
{/each} </label>
</select>
<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>
<!-- Link Input -->
<div>
<label for="link" class="mb-2 block text-green-400"> Enter Playlist or Video Link: </label>
<input
name="link"
type="url"
id="link"
bind:value={link}
required
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> </div>
<div class="flex-1"> <!-- Format Selection -->
<label for="format" class="mb-1 block text-cyan-300">Format</label> <div>
<label for="format" class="mb-2 block text-green-400"> Choose Format: </label>
<select <select
id="format" id="format"
name="format" name="format"
bind:value={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" 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 (format.label)} {#each formats as format}
<option value={format.value}>{format.label}</option> <option value={format.value}>{format.label}</option>
{/each} {/each}
</select> </select>
</div> </div>
</div>
<!-- Video Link --> <!-- Submit Button -->
<div> <button
<label for="link" class="mb-1 block text-cyan-300">Video Link</label> type="submit"
<input 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"
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"
/>
</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"
> >
DOWNLOAD Create download link
</a> </button>
{#if href}
<a class="download-link" {href}> Download </a>
{/if}
</form> </form>
</div> </div>
{#if downloading}
<DownloadManager {filename} {progress}></DownloadManager>
{/if}
<!-- Modal --> <!-- Modal -->
{#if showModal} {#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 text-green-300"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80">
<div class="w-[90%] max-w-md rounded-lg border border-green-400 bg-[#001a00] p-5 text-center"> <div
<h2 class="mb-3 text-base font-semibold">🐙 Scaricatore v{PUBLIC_VERSION} 🐙</h2> class="w-4/5 max-w-lg rounded-lg border-4 border-green-500 bg-green-900 p-6 text-center text-green-100"
<p>
Download Spotify playlists and YouTube videos with ease. Choose your source, paste a link,
select format, go!
</p>
<p class="mt-3">
<a class="text-cyan-400 underline" href="https://git.pweapon.org/odo/dl.emersa.it"
>Source Code</a
> >
<h2 class="mb-4 text-lg">🐙 Scaricatore 🐙</h2>
<p>
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>
<span class="mt-10 block">
<a class="underline" href="https://git.pweapon.org/odo/dl.emersa.it"
>Click here for the source code</a
>
</span>
<button <button
onclick={toggleModal} 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 Close
</button> </button>
</div> </div>
</div> </div>
{/if} {/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,9 +1,8 @@
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 } from '$lib/server/ytdlp';
import { isURLValid, logger, mimeTypeMap } from '$lib/server/helpers';
const validateRequest = (url: 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
@ -18,52 +17,23 @@ const validateRequest = (url: URL) => {
throw error(400, 'Currently, only YouTube is supported'); 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
};
};
export const GET: RequestHandler = async ({ url }) => {
const { format, link } = validateRequest(url);
let filename = '';
logger.debug(`Requested: ${link}`);
try { try {
logger.debug(`Fetching video data to set filename`);
// Fetch metadata for filename // Fetch metadata for filename
const ytMetadata = await getYouTubeMetadata(link); const metadata = await getYouTubeMetadata(link);
const { title, uploader } = ytMetadata; const { title, uploader } = metadata;
const safeTitle = `${uploader} - ${title}`; const safeTitle = `${uploader} - ${title}`;
filename = `${safeTitle}.${format}`; const filename = `${safeTitle}.${format}`;
} catch (err) {
logger.error(err);
logger.error('Error fetching metadata:');
throw error(500, 'Failed to fetch video metadata');
}
try { 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': `${mimeTypeMap.get(format)}`, 'Content-Type': format === 'mp3' ? 'audio/mpeg' : 'video/mp4',
'Content-Disposition': `attachment; filename="${filename}"` 'Content-Disposition': `attachment; filename="${filename}"`
} }
}); });
} catch (err) { } catch (err) {
logger.error(err); console.error('Error fetching metadata:', err);
logger.error('Filed to stream file'); throw error(500, 'Failed to fetch video metadata');
throw error(500, 'Failed to stream file');
} }
}; };

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", "start_url": "https://dl.emersa.it",
"theme_color": "rgb(34,197,94)", "theme_color": "rgb(34,197,94)",
"background": "black", "background": "black",
"orientation": "portrait", "orientation": "portrait",
"display": "fullscreen", "display": "fullscreen",
"short_name": "scaricatore", "short_name": "e-downloader",
"icons": [ "icons": [
{ {
"src": "favicon.png", "src": "favicon.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "128x128"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -1,4 +1,3 @@
import { mdsvex } from 'mdsvex';
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
@ -6,13 +5,14 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations // Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors // for more information about preprocessors
preprocess: [vitePreprocess(), mdsvex()], preprocess: vitePreprocess(),
kit: { 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() adapter: adapter()
}, }
extensions: ['.svelte', '.svx']
}; };
export default config; export default config;

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 { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit(), tailwindcss()] plugins: [sveltekit()]
}); });