feat: webowa wersja Video & Sound Downloader Pro (NestJS + React)
Some checks failed
deploy / deploy (push) Failing after 1m44s

Odwzorowanie desktopowego v6.5.6: tryby MP3/MP4/PLAYLISTA/FACEBOOK,
jakość/format, napisy, log na żywo (SSE), STOP. Docker + Gitea CI/CD,
deploy na videodownloader.naurecki.pl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 10:45:22 +02:00
commit fbc4fb0834
29 changed files with 8129 additions and 0 deletions

27
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# --- build: kompilacja TypeScript ---
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY apps/api/package.json ./
RUN npm install
COPY apps/api/ ./
RUN npm run build && npm prune --omit=dev
# --- runtime: node + ffmpeg + yt-dlp (standalone) ---
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
&& curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
-o /usr/local/bin/yt-dlp \
&& chmod a+rx /usr/local/bin/yt-dlp \
&& yt-dlp --version \
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
COPY apps/api/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV PORT=3000 \
WORK_DIR=/app/work
EXPOSE 3000
CMD ["node", "dist/main.js"]

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

4971
apps/api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
apps/api/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "vsd-api",
"version": "6.5.6",
"description": "Video & Sound Downloader Pro — API (NestJS)",
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js"
},
"dependencies": {
"@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.4.4",
"@nestjs/platform-express": "^10.4.4",
"archiver": "^7.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@types/archiver": "^6.0.2",
"@types/express": "^4.17.21",
"@types/node": "^20.16.10",
"typescript": "^5.6.2"
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { JobsModule } from './jobs/jobs.module';
import { HealthController } from './health/health.controller';
@Module({
imports: [JobsModule],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
health() {
return {
ok: true,
service: 'vsd-api',
version: process.env.APP_VERSION || '6.5.6',
trial: {
enabled: process.env.TRIAL_ENABLED === '1',
expires: process.env.TRIAL_EXPIRES || null,
daysLeft: process.env.TRIAL_DAYS_LEFT ? Number(process.env.TRIAL_DAYS_LEFT) : null,
},
};
}
}

31
apps/api/src/jobs/dto.ts Normal file
View File

@@ -0,0 +1,31 @@
import { IsBoolean, IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
export type JobMode = 'mp3' | 'mp4' | 'playlist' | 'facebook';
export class ProbeDto {
@IsString()
@MaxLength(2048)
url!: string;
}
export class CreateJobDto {
@IsString()
@MaxLength(2048)
url!: string;
@IsIn(['mp3', 'mp4', 'playlist', 'facebook'])
mode!: JobMode;
// Token formatu zależny od trybu:
// mp3 -> '320' | '256' | '192' | '128' (kbps)
// mp4/fb -> '2160' | '1440' | '1080' | '720' | '480' | 'best'
// playlist -> 'video-1080' | 'video-720' | 'audio-320'
@IsOptional()
@IsString()
@MaxLength(32)
format?: string;
@IsOptional()
@IsBoolean()
subtitles?: boolean;
}

View File

@@ -0,0 +1,92 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Res,
Sse,
MessageEvent,
} from '@nestjs/common';
import { Response } from 'express';
import { Observable, map } from 'rxjs';
import { spawn } from 'child_process';
import { CreateJobDto, ProbeDto } from './dto';
import { JobsService } from './jobs.service';
import { YtdlpService } from '../ytdlp/ytdlp.service';
@Controller('jobs')
export class JobsController {
constructor(
private readonly jobs: JobsService,
private readonly ytdlp: YtdlpService,
) {}
@Post()
create(@Body() dto: CreateJobDto) {
return this.jobs.create(dto);
}
@Sse(':id/events')
events(@Param('id') id: string): Observable<MessageEvent> {
return this.jobs.events(id).pipe(
map((ev) => ({ data: JSON.stringify(ev), type: ev.kind }) as MessageEvent),
);
}
@Delete(':id')
stop(@Param('id') id: string) {
return this.jobs.stop(id);
}
@Get(':id/file')
async file(@Param('id') id: string, @Res() res: Response) {
const { path: filePath, name, job } = this.jobs.getOutput(id);
res.download(filePath, name, (err) => {
// po wysłaniu (lub błędzie) sprzątamy katalog joba
void this.jobs.cleanup(job, 2000);
if (err && !res.headersSent) res.status(500).end();
});
}
}
@Controller('probe')
export class ProbeController {
constructor(private readonly ytdlp: YtdlpService) {}
/** Podgląd materiału: tytuł, czas, dostępne jakości i języki napisów. */
@Post()
probe(@Body() dto: ProbeDto): Promise<unknown> {
return new Promise((resolve, reject) => {
const child = spawn(this.ytdlp.bin, this.ytdlp.probeArgs(dto.url));
let out = '';
let err = '';
child.stdout!.on('data', (b) => (out += b.toString()));
child.stderr!.on('data', (b) => (err += b.toString()));
child.on('close', (code) => {
if (code !== 0) return reject(new Error(err.trim() || `yt-dlp kod ${code}`));
try {
const j = JSON.parse(out);
const heights = Array.from(
new Set(
(j.formats || [])
.map((f: any) => f.height)
.filter((h: any) => typeof h === 'number'),
),
).sort((a: any, b: any) => b - a);
resolve({
title: j.title,
thumbnail: j.thumbnail,
duration: j.duration,
uploader: j.uploader,
heights,
subtitles: Object.keys(j.subtitles || {}),
});
} catch (e) {
reject(e);
}
});
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { JobsController, ProbeController } from './jobs.controller';
import { JobsService } from './jobs.service';
import { YtdlpService } from '../ytdlp/ytdlp.service';
@Module({
controllers: [JobsController, ProbeController],
providers: [JobsService, YtdlpService],
})
export class JobsModule {}

View File

@@ -0,0 +1,209 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ReplaySubject } from 'rxjs';
import { spawn, ChildProcess } from 'child_process';
import { promises as fs, createWriteStream } from 'fs';
import * as path from 'path';
import * as os from 'os';
import archiver from 'archiver';
import { CreateJobDto, JobMode } from './dto';
import { YtdlpService, buildArgs } from '../ytdlp/ytdlp.service';
export interface JobEvent {
kind: 'log' | 'progress' | 'status' | 'done' | 'error';
[k: string]: unknown;
}
interface Job {
id: string;
mode: JobMode;
workDir: string;
child?: ChildProcess;
subject: ReplaySubject<JobEvent>;
status: 'running' | 'processing' | 'done' | 'error' | 'stopped';
outputFile?: string; // ścieżka gotowego pliku do pobrania
createdAt: number;
}
const WORK_ROOT = process.env.WORK_DIR || path.join(os.tmpdir(), 'vsd-work');
const JOB_TTL_MS = 30 * 60 * 1000; // 30 min na porzucone joby
@Injectable()
export class JobsService {
private readonly log = new Logger('JobsService');
private jobs = new Map<string, Job>();
private seq = 0;
constructor(private readonly ytdlp: YtdlpService) {
void fs.mkdir(WORK_ROOT, { recursive: true });
setInterval(() => this.gc(), 5 * 60 * 1000).unref();
}
private newId(): string {
this.seq += 1;
return `${this.seq.toString(36)}${process.pid.toString(36)}${this.seq * 7919}`;
}
async create(dto: CreateJobDto): Promise<{ id: string }> {
const id = this.newId();
const workDir = path.join(WORK_ROOT, id);
await fs.mkdir(workDir, { recursive: true });
const job: Job = {
id,
mode: dto.mode,
workDir,
subject: new ReplaySubject<JobEvent>(1000),
status: 'running',
createdAt: Date.now(),
};
this.jobs.set(id, job);
this.run(job, dto).catch((e) => this.fail(job, String(e?.message || e)));
return { id };
}
private emit(job: Job, ev: JobEvent) {
job.subject.next(ev);
}
private async run(job: Job, dto: CreateJobDto) {
const outTemplate = path.join(job.workDir, '%(playlist_index&{} - |)s%(title)s.%(ext)s');
const args = buildArgs(dto.mode, dto.url, dto.format, !!dto.subtitles, outTemplate);
this.emit(job, { kind: 'status', status: 'running' });
this.emit(job, { kind: 'log', line: `$ yt-dlp ${args.join(' ')}` });
const child = spawn(this.ytdlp.bin, args, { cwd: job.workDir });
job.child = child;
const onLine = (buf: Buffer, stream: 'out' | 'err') => {
const text = buf.toString('utf8');
for (const raw of text.split(/\r?\n/)) {
const line = raw.trimEnd();
if (!line) continue;
const m = line.match(/(\d{1,3}(?:\.\d+)?)%/);
if (m && /\[download\]/.test(line)) {
this.emit(job, { kind: 'progress', percent: Math.min(100, parseFloat(m[1])) });
}
this.emit(job, { kind: 'log', line, stream });
}
};
child.stdout!.on('data', (b) => onLine(b, 'out'));
child.stderr!.on('data', (b) => onLine(b, 'err'));
child.on('close', async (code) => {
if (job.status === 'stopped') return;
if (code !== 0) {
return this.fail(job, `yt-dlp zakończył się kodem ${code}`);
}
try {
job.status = 'processing';
this.emit(job, { kind: 'status', status: 'processing' });
const out = await this.collectOutput(job);
job.outputFile = out.path;
job.status = 'done';
this.emit(job, {
kind: 'done',
file: { name: out.name, size: out.size },
downloadUrl: `/api/jobs/${job.id}/file`,
});
this.emit(job, { kind: 'status', status: 'done' });
job.subject.complete();
} catch (e: any) {
this.fail(job, String(e?.message || e));
}
});
}
/** Zbiera wynik: pojedynczy plik, albo ZIP gdy powstało wiele plików (playlista). */
private async collectOutput(job: Job): Promise<{ path: string; name: string; size: number }> {
const entries = (await fs.readdir(job.workDir))
.filter((f) => !f.endsWith('.part') && !f.endsWith('.ytdl') && !f.endsWith('.zip'));
if (entries.length === 0) throw new Error('Brak pliku wyjściowego po pobraniu.');
if (entries.length === 1) {
const p = path.join(job.workDir, entries[0]);
const st = await fs.stat(p);
this.emit(job, { kind: 'log', line: `OK: gotowy plik — ${entries[0]} (${fmtSize(st.size)})` });
return { path: p, name: entries[0], size: st.size };
}
// Wiele plików → ZIP
this.emit(job, { kind: 'log', line: `Scalanie ${entries.length} plików do archiwum ZIP...` });
const zipName = `playlist-${job.id}.zip`;
const zipPath = path.join(job.workDir, zipName);
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 0 } });
output.on('close', () => resolve());
archive.on('error', reject);
archive.pipe(output);
for (const f of entries) archive.file(path.join(job.workDir, f), { name: f });
void archive.finalize();
});
const st = await fs.stat(zipPath);
this.emit(job, { kind: 'log', line: `OK: gotowe archiwum — ${zipName} (${fmtSize(st.size)})` });
return { path: zipPath, name: zipName, size: st.size };
}
private fail(job: Job, message: string) {
if (job.status === 'stopped') return;
job.status = 'error';
this.log.warn(`Job ${job.id} błąd: ${message}`);
this.emit(job, { kind: 'log', line: `BŁĄD: ${message}` });
this.emit(job, { kind: 'error', message });
this.emit(job, { kind: 'status', status: 'error' });
job.subject.complete();
}
events(id: string): ReplaySubject<JobEvent> {
const job = this.jobs.get(id);
if (!job) throw new NotFoundException('Nie znaleziono joba');
return job.subject;
}
stop(id: string) {
const job = this.jobs.get(id);
if (!job) throw new NotFoundException('Nie znaleziono joba');
job.status = 'stopped';
if (job.child && !job.child.killed) job.child.kill('SIGKILL');
this.emit(job, { kind: 'log', line: 'Przerwano przez użytkownika (STOP).' });
this.emit(job, { kind: 'status', status: 'stopped' });
job.subject.complete();
void this.cleanup(job, 1000);
return { ok: true };
}
getOutput(id: string): { path: string; name: string; job: Job } {
const job = this.jobs.get(id);
if (!job || !job.outputFile) throw new NotFoundException('Plik niedostępny');
return { path: job.outputFile, name: path.basename(job.outputFile), job };
}
/** Sprzątanie katalogu joba (po wysłaniu pliku lub TTL). */
async cleanup(job: Job, delayMs = 0) {
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
try {
await fs.rm(job.workDir, { recursive: true, force: true });
} catch {
/* ignore */
}
this.jobs.delete(job.id);
}
private gc() {
const now = Date.now();
for (const job of this.jobs.values()) {
if (now - job.createdAt > JOB_TTL_MS) void this.cleanup(job);
}
}
}
function fmtSize(bytes: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB'];
let n = bytes;
let i = 0;
while (n >= 1024 && i < u.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(1)} ${u[i]}`;
}

19
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: false }),
);
// CORS przydatne tylko w devie (front na innym porcie); w produkcji nginx proxuje /api
app.enableCors({ origin: process.env.CORS_ORIGIN || true });
const port = Number(process.env.PORT || 3000);
await app.listen(port, '0.0.0.0');
// eslint-disable-next-line no-console
console.log(`VSD API nasłuchuje na :${port}`);
}
bootstrap();

View File

@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { JobMode } from '../jobs/dto';
const YTDLP = process.env.YTDLP_BIN || 'yt-dlp';
/**
* Buduje argumenty yt-dlp zależne od trybu. `outTemplate` to pełna ścieżka
* szablonu wewnątrz katalogu roboczego joba.
*/
export function buildArgs(
mode: JobMode,
url: string,
format: string | undefined,
subtitles: boolean,
outTemplate: string,
): string[] {
const base = [
'--newline',
'--progress',
'--no-color',
'--restrict-filenames',
'--no-mtime',
'-o',
outTemplate,
];
// Napisy: tylko realne (bez automatycznie generowanych przez YouTube).
const subs = subtitles
? ['--write-subs', '--no-write-auto-subs', '--sub-langs', 'all', '--embed-subs']
: [];
switch (mode) {
case 'mp3': {
const kbps = /^\d+$/.test(format || '') ? format : '320';
return [
...base,
'--no-playlist',
'-x',
'--audio-format',
'mp3',
'--audio-quality',
`${kbps}K`,
'--embed-thumbnail',
'--embed-metadata',
url,
];
}
case 'mp4':
case 'facebook': {
const sel =
format && format !== 'best' && /^\d+$/.test(format)
? `bv*[height<=${format}]+ba/b[height<=${format}]/b`
: 'bv*+ba/b';
return [
...base,
'--no-playlist',
'-f',
sel,
'--merge-output-format',
'mp4',
'--embed-metadata',
...subs,
url,
];
}
case 'playlist': {
const f = format || 'video-1080';
if (f.startsWith('audio')) {
const kbps = f.split('-')[1] || '320';
return [
...base,
'--yes-playlist',
'-x',
'--audio-format',
'mp3',
'--audio-quality',
`${kbps}K`,
'--embed-metadata',
url,
];
}
const h = f.split('-')[1] || '1080';
return [
...base,
'--yes-playlist',
'-f',
`bv*[height<=${h}]+ba/b[height<=${h}]/b`,
'--merge-output-format',
'mp4',
'--embed-metadata',
...subs,
url,
];
}
}
}
@Injectable()
export class YtdlpService {
bin = YTDLP;
/** Argumenty `yt-dlp -J` do podglądu materiału (tytuł, jakości, napisy). */
probeArgs(url: string): string[] {
return ['-J', '--no-warnings', '--no-playlist', url];
}
}

17
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"moduleResolution": "node",
"outDir": "./dist",
"baseUrl": "./",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": false,
"sourceMap": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true
}
}

13
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# --- build: bundling Vite/React ---
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY apps/web/package.json ./
RUN npm install
COPY apps/web/ ./
RUN npm run build
# --- serwowanie statyk + proxy /api ---
FROM nginx:alpine
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Video &amp; Sound Downloader Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/web/nginx.conf Normal file
View File

@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API + SSE (strumień logów) — bez buforowania, długie timeouty na pobieranie
location /api/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding off;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

1732
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
apps/web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "vsd-web",
"version": "6.5.6",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

335
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,335 @@
import { useEffect, useRef, useState } from 'react';
type Mode = 'mp3' | 'mp4' | 'playlist' | 'facebook';
interface ModeDef {
key: Mode;
title: string;
subtitle: string;
icon: string;
accent: string;
button: string;
formats: { value: string; label: string }[];
}
const MODES: ModeDef[] = [
{
key: 'mp3',
title: 'MP3',
subtitle: 'Pobierz dźwięk',
icon: '♫',
accent: '#a855f7',
button: 'POBIERZ DŹWIĘK',
formats: [
{ value: '320', label: 'MP3 320 kbps' },
{ value: '256', label: 'MP3 256 kbps' },
{ value: '192', label: 'MP3 192 kbps' },
{ value: '128', label: 'MP3 128 kbps' },
],
},
{
key: 'mp4',
title: 'MP4',
subtitle: 'Pobierz wideo',
icon: '■',
accent: '#3b82f6',
button: 'POBIERZ WIDEO',
formats: [
{ value: 'best', label: 'MP4 najlepsza' },
{ value: '2160', label: 'MP4 2160p (4K)' },
{ value: '1440', label: 'MP4 1440p' },
{ value: '1080', label: 'MP4 1080p' },
{ value: '720', label: 'MP4 720p' },
{ value: '480', label: 'MP4 480p' },
],
},
{
key: 'playlist',
title: 'PLAYLISTA',
subtitle: 'Pobierz całą listę',
icon: '▦',
accent: '#22c55e',
button: 'POBIERZ PLAYLISTĘ',
formats: [
{ value: 'video-1080', label: 'Wideo MP4 1080p' },
{ value: 'video-720', label: 'Wideo MP4 720p' },
{ value: 'audio-320', label: 'Audio MP3 320 kbps' },
],
},
{
key: 'facebook',
title: 'FACEBOOK',
subtitle: 'Pobierz wideo',
icon: 'f',
accent: '#60a5fa',
button: 'POBIERZ WIDEO',
formats: [
{ value: 'best', label: 'MP4 najlepsza' },
{ value: '1080', label: 'MP4 1080p' },
{ value: '720', label: 'MP4 720p' },
{ value: '480', label: 'MP4 480p' },
],
},
];
const VERSION = '6.5.6';
export default function App() {
const [url, setUrl] = useState('');
const [mode, setMode] = useState<Mode>('mp3');
const [format, setFormat] = useState('320');
const [subtitles, setSubtitles] = useState(false);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(0);
const [log, setLog] = useState<string[]>(['OK: wszystko gotowe do pobierania.']);
const [trial, setTrial] = useState<{ daysLeft: number | null; expires: string | null } | null>(null);
const esRef = useRef<EventSource | null>(null);
const jobRef = useRef<string | null>(null);
const logEndRef = useRef<HTMLDivElement | null>(null);
const def = MODES.find((m) => m.key === mode)!;
useEffect(() => {
// przy zmianie trybu ustaw domyślny format
setFormat(MODES.find((m) => m.key === mode)!.formats[0].value);
}, [mode]);
useEffect(() => {
fetch('/api/health')
.then((r) => r.json())
.then((h) => {
if (h?.trial?.enabled) setTrial({ daysLeft: h.trial.daysLeft, expires: h.trial.expires });
})
.catch(() => {});
}, []);
useEffect(() => {
logEndRef.current?.scrollIntoView({ block: 'end' });
}, [log]);
function appendLog(line: string) {
setLog((l) => [...l.slice(-400), line]);
}
async function paste() {
try {
const t = await navigator.clipboard.readText();
if (t) setUrl(t.trim());
} catch {
appendLog('Nie udało się odczytać schowka (brak uprawnień przeglądarki).');
}
}
function reset() {
esRef.current?.close();
esRef.current = null;
jobRef.current = null;
setBusy(false);
}
async function start() {
if (!url.trim()) {
appendLog('BŁĄD: wklej najpierw link.');
return;
}
setBusy(true);
setProgress(0);
setLog([`Start: ${def.title}${url.trim()}`]);
try {
const res = await fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim(), mode, format, subtitles }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { id } = await res.json();
jobRef.current = id;
const es = new EventSource(`/api/jobs/${id}/events`);
esRef.current = es;
es.onmessage = (e) => handleEvent(e.data);
es.onerror = () => {
// strumień zamknięty (koniec joba lub błąd sieci)
es.close();
};
} catch (e: any) {
appendLog(`BŁĄD: ${e?.message || e}`);
reset();
}
}
function handleEvent(data: string) {
let ev: any;
try {
ev = JSON.parse(data);
} catch {
return;
}
switch (ev.kind) {
case 'log':
appendLog(ev.line);
break;
case 'progress':
setProgress(ev.percent);
break;
case 'done': {
setProgress(100);
appendLog(`Pobieranie zakończone: ${ev.file?.name}`);
// wywołaj pobranie pliku przez przeglądarkę
const a = document.createElement('a');
a.href = ev.downloadUrl;
a.download = ev.file?.name || '';
document.body.appendChild(a);
a.click();
a.remove();
reset();
break;
}
case 'error':
appendLog(`BŁĄD: ${ev.message}`);
reset();
break;
default:
break;
}
}
async function stop() {
const id = jobRef.current;
if (id) {
try {
await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
} catch {
/* ignore */
}
}
appendLog('Zatrzymano.');
reset();
}
return (
<div className="app">
<div className="card">
<header className="head">
<div className="logo"></div>
<div className="head-text">
<h1>Video &amp; Sound Downloader Pro</h1>
<p>Pobieraj audio, wideo i playlisty z jednego panelu</p>
</div>
<span className="badge">v{VERSION}</span>
</header>
<label className="field-label">Link:</label>
<div className="link-row">
<input
className="link-input"
placeholder="Wklej link (YouTube, YouTube Music, Shorts, Facebook, LinkedIn, ARTE TV…)"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button className="icon-btn" title="Wklej ze schowka" onClick={paste}>
{'\u{1F517}'}
</button>
</div>
<label className="field-label">Wybierz opcję:</label>
<div className="modes">
{MODES.map((m) => (
<button
key={m.key}
className={`mode ${mode === m.key ? 'active' : ''}`}
style={mode === m.key ? { borderColor: m.accent } : undefined}
onClick={() => setMode(m.key)}
>
<span className="mode-icon" style={{ color: m.accent }}>
{m.icon}
</span>
<span className="mode-title">{m.title}</span>
<span className="mode-sub">{m.subtitle}</span>
</button>
))}
</div>
<div className="row2">
<div className="col">
<label className="field-label">Jakość / Format:</label>
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{def.formats.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div className="col">
<label className="field-label">Zapisz do:</label>
<div className="save-to">
<span>Pobranie przez przeglądarkę (folder Pobrane)</span>
<label className="subs">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
disabled={mode === 'mp3'}
/>
Napisy
</label>
</div>
</div>
</div>
<div className="actions">
<button
className="download-btn"
onClick={start}
disabled={busy}
style={{ opacity: busy ? 0.6 : 1 }}
>
{busy ? `Pobieram… ${Math.round(progress)}%` : `${def.button}`}
</button>
<button className="stop-btn" onClick={stop} disabled={!busy}>
STOP
</button>
</div>
{busy && (
<div className="progress">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
)}
{trial && (
<div className="trial">
Tryb testowy: zostało {trial.daysLeft} dni. Wygasa: {trial.expires}.
</div>
)}
<div className="logbox">
<div className="logbox-title">Log</div>
<div className="logbox-body">
{log.map((l, i) => (
<div key={i} className="logline">
{l}
</div>
))}
<div ref={logEndRef} />
</div>
</div>
<footer className="foot">
<button className="ghost" onClick={() => alert('Wsparcie: napisz na support@naurecki.pl')}>
Wsparcie
</button>
<button
className="ghost"
onClick={() =>
alert('Video & Sound Downloader Pro — webowa wersja oparta o yt-dlp + FFmpeg.')
}
>
O mnie
</button>
</footer>
</div>
</div>
);
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

136
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,136 @@
:root {
--bg: #0b1220;
--card: #111a2b;
--panel: #0e1626;
--panel2: #16223a;
--border: #24324d;
--text: #e6edf7;
--muted: #8aa0bd;
--blue: #2563eb;
--blue-bright: #3b82f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.app {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 24px 12px;
}
.card {
width: 100%;
max-width: 960px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px 28px 20px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.head { display: flex; align-items: center; gap: 16px; margin-bottom: 22px; }
.logo {
width: 54px; height: 54px; border-radius: 12px;
background: var(--panel2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 26px; color: var(--blue-bright);
}
.head-text { flex: 1; }
.head-text h1 { margin: 0; font-size: 24px; font-weight: 700; }
.head-text p { margin: 3px 0 0; color: var(--muted); font-size: 13px; }
.badge {
background: var(--panel2); color: var(--blue-bright);
border: 1px solid var(--border); border-radius: 10px;
padding: 5px 10px; font-size: 12px; font-weight: 600;
}
.field-label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; }
.link-row { display: flex; gap: 10px; }
.link-input {
flex: 1; background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 13px 14px; color: var(--text); font-size: 14px;
}
.link-input:focus { outline: none; border-color: var(--blue-bright); }
.icon-btn {
width: 56px; background: var(--panel2); border: 1px solid var(--border);
border-radius: 10px; color: var(--blue-bright); font-size: 18px; cursor: pointer;
}
.icon-btn:hover { border-color: var(--blue-bright); }
.modes { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.mode {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 10px; cursor: pointer; color: var(--text);
display: flex; flex-direction: column; align-items: center; gap: 6px;
transition: border-color .15s, background .15s;
}
.mode:hover { background: var(--panel2); }
.mode.active { background: #15233c; box-shadow: 0 0 0 1px rgba(59,130,246,.4) inset; }
.mode-icon { font-size: 26px; line-height: 1; }
.mode-title { font-weight: 700; font-size: 15px; }
.mode-sub { font-size: 11px; color: var(--muted); }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.col { display: flex; flex-direction: column; }
.select {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; color: var(--text); font-size: 14px; appearance: none;
}
.select:focus { outline: none; border-color: var(--blue-bright); }
.save-to {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 14px; color: var(--muted); font-size: 13px;
display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.subs { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 13px; cursor: pointer; }
.actions { display: flex; gap: 12px; margin-top: 18px; }
.download-btn {
flex: 1; background: var(--blue); border: none; border-radius: 10px;
color: #fff; font-size: 16px; font-weight: 700; padding: 16px; cursor: pointer;
}
.download-btn:hover:not(:disabled) { background: var(--blue-bright); }
.stop-btn {
width: 140px; background: #3a1d22; border: 1px solid #6b2b33; border-radius: 10px;
color: #ff8a8a; font-size: 15px; font-weight: 700; cursor: pointer;
}
.stop-btn:disabled { opacity: .5; cursor: default; }
.progress { margin-top: 12px; height: 6px; background: var(--panel); border-radius: 4px; overflow: hidden; }
.progress-bar { height: 100%; background: var(--blue-bright); transition: width .2s; }
.trial {
margin-top: 14px; background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 14px; color: var(--muted); font-size: 13px;
}
.logbox { margin-top: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
.logbox-title { font-weight: 700; margin-bottom: 8px; }
.logbox-body {
background: #0a1120; border: 1px solid var(--border); border-radius: 8px;
height: 140px; overflow-y: auto; padding: 10px 12px;
font-family: 'Consolas', 'Courier New', monospace; font-size: 12.5px; color: #b9c6db;
}
.logline { white-space: pre-wrap; word-break: break-all; line-height: 1.5; }
.foot { display: flex; justify-content: space-between; margin-top: 16px; }
.ghost {
background: transparent; border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 9px 18px; font-size: 13px; cursor: pointer;
}
.ghost:hover { border-color: var(--blue-bright); }
@media (max-width: 720px) {
.modes { grid-template-columns: repeat(2, 1fr); }
.row2 { grid-template-columns: 1fr; }
}

19
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

15
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// W devie proxujemy /api do lokalnego NestJS (port 3000).
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});