feat: webowa wersja Video & Sound Downloader Pro (NestJS + React)
Some checks failed
deploy / deploy (push) Failing after 1m44s
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:
9
apps/api/src/app.module.ts
Normal file
9
apps/api/src/app.module.ts
Normal 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 {}
|
||||
18
apps/api/src/health/health.controller.ts
Normal file
18
apps/api/src/health/health.controller.ts
Normal 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
31
apps/api/src/jobs/dto.ts
Normal 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;
|
||||
}
|
||||
92
apps/api/src/jobs/jobs.controller.ts
Normal file
92
apps/api/src/jobs/jobs.controller.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/api/src/jobs/jobs.module.ts
Normal file
10
apps/api/src/jobs/jobs.module.ts
Normal 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 {}
|
||||
209
apps/api/src/jobs/jobs.service.ts
Normal file
209
apps/api/src/jobs/jobs.service.ts
Normal 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
19
apps/api/src/main.ts
Normal 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();
|
||||
106
apps/api/src/ytdlp/ytdlp.service.ts
Normal file
106
apps/api/src/ytdlp/ytdlp.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user