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
}
}