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:
47
.gitea/workflows/deploy.yml
Normal file
47
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: deploy
|
||||
|
||||
# Build + deploy na tym samym hoście: runner ma zamontowany docker.sock,
|
||||
# więc `docker compose` operuje na demonie serwera (naurecki.pl).
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-videodownloader
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sanity — docker dostępny
|
||||
run: docker version && docker compose version
|
||||
|
||||
- name: Build obrazów
|
||||
run: docker compose -f docker-compose.prod.yml build
|
||||
|
||||
- name: Start stacka
|
||||
env:
|
||||
PUBLIC_BASE_URL: https://videodownloader.naurecki.pl
|
||||
TRIAL_ENABLED: ${{ vars.TRIAL_ENABLED }}
|
||||
TRIAL_DAYS_LEFT: ${{ vars.TRIAL_DAYS_LEFT }}
|
||||
TRIAL_EXPIRES: ${{ vars.TRIAL_EXPIRES }}
|
||||
run: docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
- name: Czekaj na gotowość API
|
||||
run: |
|
||||
ok=0
|
||||
for i in $(seq 1 40); do
|
||||
if docker compose -f docker-compose.prod.yml exec -T api \
|
||||
node -e "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; then
|
||||
echo "API gotowe"; ok=1; break
|
||||
fi
|
||||
echo "czekam na API ($i)..."; sleep 2
|
||||
done
|
||||
[ "$ok" = "1" ] || { echo "API nie wstało"; docker compose -f docker-compose.prod.yml logs --tail=80 api; exit 1; }
|
||||
|
||||
- name: Sprzątanie nieużywanych obrazów
|
||||
run: docker image prune -f
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
apps/api/work/
|
||||
apps/web/dist/
|
||||
38
DEPLOY.md
Normal file
38
DEPLOY.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Wdrożenie — videodownloader.naurecki.pl
|
||||
|
||||
## Pipeline
|
||||
Push do `main` uruchamia `.gitea/workflows/deploy.yml` na runnerze Gitea
|
||||
(z zamontowanym `docker.sock`). Runner buduje obrazy z `docker-compose.prod.yml`
|
||||
i robi `up -d` na demonie Dockera serwera. Serwis `web` (nginx) słucha na
|
||||
`127.0.0.1:8085`, `api` (NestJS) wewnętrznie + diagnostycznie na `127.0.0.1:3105`.
|
||||
|
||||
## Reverse proxy (Apache) — jednorazowo na serwerze
|
||||
Plik `/etc/apache2/sites-available/videodownloader.naurecki.pl.conf`:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName videodownloader.naurecki.pl
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
# SSE (log na żywo) wymaga flushowania pakietów
|
||||
ProxyPass / http://127.0.0.1:8085/ flushpackets=on
|
||||
ProxyPassReverse / http://127.0.0.1:8085/
|
||||
ErrorLog ${APACHE_LOG_DIR}/videodownloader-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/videodownloader-access.log combined
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Aktywacja + SSL (Let's Encrypt):
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http
|
||||
sudo a2ensite videodownloader.naurecki.pl
|
||||
sudo apache2ctl configtest && sudo systemctl reload apache2
|
||||
sudo certbot --apache -d videodownloader.naurecki.pl --redirect -n \
|
||||
--agree-tos -m dariusz@naurecki.pl
|
||||
```
|
||||
|
||||
DNS: `*.naurecki.pl` (wildcard) już wskazuje serwer — bez dodatkowej konfiguracji.
|
||||
|
||||
## Tryb testowy (opcjonalny banner)
|
||||
Ustaw zmienne repozytorium w Gitei (Settings → Actions → Variables):
|
||||
`TRIAL_ENABLED=1`, `TRIAL_DAYS_LEFT=60`, `TRIAL_EXPIRES=2026-08-27`.
|
||||
66
PROMPT.md
Normal file
66
PROMPT.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Master-prompt i plan wdrożenia
|
||||
|
||||
Dokument zawiera (1) gotowy **prompt** do wygenerowania aplikacji od zera oraz
|
||||
(2) **plan** realizacji i wdrożenia na infrastrukturze `naurecki.pl`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Master-prompt (do odtworzenia aplikacji)
|
||||
|
||||
> Zbuduj webową aplikację SPA „Video & Sound Downloader Pro” — webowy odpowiednik
|
||||
> desktopowego narzędzia łączącego `yt-dlp` i `FFmpeg`. **Stack: backend NestJS
|
||||
> (TypeScript), frontend React + Vite (TypeScript).** Monorepo `apps/api`, `apps/web`.
|
||||
>
|
||||
> **Backend (NestJS):**
|
||||
> - `POST /api/probe` — przyjmuje `{ url }`, uruchamia `yt-dlp -J` i zwraca tytuł,
|
||||
> miniaturę, czas trwania, dostępne jakości oraz listę języków napisów.
|
||||
> - `POST /api/jobs` — `{ url, mode, format, subtitles }`, gdzie `mode ∈
|
||||
> {mp3, mp4, playlist, facebook}`. Tworzy job w katalogu tymczasowym, uruchamia
|
||||
> `yt-dlp` (+ `ffmpeg`) z argumentami zależnymi od trybu i zwraca `jobId`.
|
||||
> - `GET /api/jobs/:id/events` — strumień **SSE** z liniami logu i procentem postępu
|
||||
> (parsowanie `--newline --progress`); zdarzenia `log`, `progress`, `done`, `error`.
|
||||
> - `GET /api/jobs/:id/file` — strumień gotowego pliku do przeglądarki, po wysłaniu
|
||||
> sprzątnięcie katalogu joba (TTL-cleanup dla porzuconych).
|
||||
> - `DELETE /api/jobs/:id` — STOP: zabija proces yt-dlp/ffmpeg joba.
|
||||
> - Joby w pamięci (EventEmitter) — bez bazy, lekko i „portable”.
|
||||
> - Mapowanie trybów: `mp3` → `-x --audio-format mp3 --audio-quality <kbps>`;
|
||||
> `mp4`/`facebook` → `-f "bv*[height<=H]+ba/b" --merge-output-format mp4`;
|
||||
> `playlist` → `--yes-playlist`, wynik pakowany do ZIP. Napisy:
|
||||
> `--write-subs --sub-langs all --embed-subs` (bez auto-napisów: `--no-write-auto-subs`).
|
||||
>
|
||||
> **Frontend (React) — odwzoruj 1:1 zrzut ekranu desktopu:**
|
||||
> ciemny motyw, akcenty niebieskie. Nagłówek z ikoną i wersją; pole **Link** z
|
||||
> przyciskiem wklejenia ze schowka; sekcja **„Wybierz opcję”** z 4 kafelkami
|
||||
> (MP3 / MP4 / PLAYLISTA / FACEBOOK, ikony i kolory jak na zrzucie, aktywny kafel
|
||||
> podświetlony); **„Jakość / Format”** jako dropdown zależny od trybu; w miejscu
|
||||
> „Zapisz do” — informacja o pobraniu przez przeglądarkę; duży przycisk
|
||||
> **„POBIERZ …”** + **STOP**; **banner trybu testowego**; panel **Log** na żywo
|
||||
> (z SSE); stopka **Wsparcie** / **O mnie**.
|
||||
>
|
||||
> **Wymagania niefunkcjonalne:** obsługa długich procesów (SSE bez buforowania),
|
||||
> czytelne komunikaty końcowe i logi, scalanie audio+wideo, brak pozostałości po
|
||||
> jobie. Konteneryzacja: obraz API zawiera `python3 + yt-dlp + ffmpeg`; web to
|
||||
> nginx serwujący build React i proxujący `/api` (z wyłączonym buforowaniem dla SSE).
|
||||
|
||||
---
|
||||
|
||||
## 2. Plan realizacji i wdrożenia
|
||||
|
||||
| # | Krok | Szczegóły |
|
||||
|---|------|-----------|
|
||||
| 1 | **Repo** | `gitea.naurecki.pl/dariusz/videodownloader` (push HTTPS-token). |
|
||||
| 2 | **Backend** | NestJS: moduły `jobs`, `ytdlp`, `health`; SSE; STOP; ZIP playlist. |
|
||||
| 3 | **Frontend** | React+Vite, UI ze zrzutu, klient SSE do panelu Log. |
|
||||
| 4 | **Docker** | `apps/api/Dockerfile` (node+yt-dlp+ffmpeg), `apps/web/Dockerfile` (nginx). |
|
||||
| 5 | **Compose** | `docker-compose.prod.yml`: `web` → `127.0.0.1:8085`, `api` wewnętrznie. |
|
||||
| 6 | **CI/CD** | `.gitea/workflows/deploy.yml`: push `main` → build + `up -d` (docker.sock). |
|
||||
| 7 | **Reverse proxy** | vhost Apache `videodownloader.naurecki.pl` → `127.0.0.1:8085`. |
|
||||
| 8 | **SSL** | `certbot --apache -d videodownloader.naurecki.pl`. |
|
||||
| 9 | **DNS** | Gotowe — `*.naurecki.pl` (wildcard) już wskazuje `212.109.167.206`. |
|
||||
| 10 | **Weryfikacja** | `https://videodownloader.naurecki.pl` + test pobrania. |
|
||||
|
||||
**Ustalenia infrastrukturalne** (zweryfikowane na serwerze):
|
||||
- Serwer `naurecki.pl` (= `mail.naurecki.pl`, `212.109.167.206`): Apache + certbot,
|
||||
aktywny `gitea-runner` z zamontowanym `docker.sock`.
|
||||
- Wolny port loopback: **8085**.
|
||||
- Wzorzec deployu zgodny z istniejącym projektem `gdziebts`.
|
||||
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Video & Sound Downloader Pro — wersja web (SPA)
|
||||
|
||||
Webowy odpowiednik desktopowego narzędzia **Video & Sound Downloader Pro v6.5.6**.
|
||||
Łączy `yt-dlp` + `FFmpeg` w jednym panelu: pobieranie audio (MP3), wideo (MP4),
|
||||
całych playlist oraz materiałów z Facebooka — bez wpisywania komend i bez ręcznej
|
||||
konwersji. Backend: **NestJS**, frontend: **React (Vite)**. Obraz portowany jest do
|
||||
Dockera i wdrażany na `https://videodownloader.naurecki.pl`.
|
||||
|
||||
> Aplikacja działa na bazie [yt-dlp](https://github.com/yt-dlp/yt-dlp) i obsługuje
|
||||
> wszystkie platformy, które obsługuje yt-dlp (YouTube, YouTube Music, YouTube Shorts,
|
||||
> Facebook, LinkedIn, ARTE TV i wiele innych).
|
||||
|
||||
---
|
||||
|
||||
## Architektura
|
||||
|
||||
```
|
||||
videodownloader/
|
||||
├── apps/
|
||||
│ ├── api/ # NestJS — joby pobierania, yt-dlp+ffmpeg, postęp SSE
|
||||
│ │ ├── src/
|
||||
│ │ └── Dockerfile # node + python3 + yt-dlp + ffmpeg
|
||||
│ └── web/ # React + Vite (TS) — UI odwzorowany ze zrzutu
|
||||
│ ├── src/
|
||||
│ ├── nginx.conf # serwuje statyki + proxy /api (+ SSE bez buforowania)
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.prod.yml # web → 127.0.0.1:8085, api wewnętrznie
|
||||
└── .gitea/workflows/deploy.yml
|
||||
```
|
||||
|
||||
Ruch publiczny: `Apache (vhost videodownloader.naurecki.pl, SSL)` → `127.0.0.1:8085`
|
||||
(kontener **web**, nginx) → statyki React + proxy `/api` → kontener **api** (NestJS).
|
||||
|
||||
## Funkcje (odwzorowane ze zrzutu desktopowego)
|
||||
|
||||
- **Pole Link** z przyciskiem „wklej ze schowka”.
|
||||
- **4 tryby**: `MP3` (pobierz dźwięk), `MP4` (pobierz wideo), `PLAYLISTA`
|
||||
(pobierz całą listę → ZIP), `FACEBOOK` (pobierz wideo).
|
||||
- **Jakość / Format** — lista zależna od trybu (MP3 320/256/192/128 kbps;
|
||||
MP4 2160/1440/1080/720/480p).
|
||||
- **Napisy** — opcja pobrania dostępnych napisów (`--write-subs --embed-subs`);
|
||||
zgodnie z oryginałem **nie** dotyczy automatycznie generowanych napisów YouTube.
|
||||
- Duży przycisk **POBIERZ** + **STOP** (przerwanie joba).
|
||||
- **Log na żywo** (SSE) — komunikaty postępu, konwersji i scalania audio/wideo.
|
||||
- Banner **trybu testowego** (kosmetyczny, sterowany zmienną `TRIAL_*`).
|
||||
- Przyciski **Wsparcie** / **O mnie**.
|
||||
|
||||
> Różnica względem wersji desktop: pole „Zapisz do” (ścieżka dyskowa Windows) w
|
||||
> wersji web zostaje zastąpione pobraniem gotowego pliku przez przeglądarkę. Serwer
|
||||
> przetwarza materiał w katalogu tymczasowym i po wysłaniu pliku sprząta po sobie
|
||||
> (zgodnie z „bez zbędnych pozostałości i bez ręcznego sprzątania folderu”).
|
||||
|
||||
## Rozwój lokalny
|
||||
|
||||
```bash
|
||||
# API
|
||||
cd apps/api && npm install && npm run start:dev # http://localhost:3000/api
|
||||
# WEB
|
||||
cd apps/web && npm install && npm run dev # http://localhost:5173
|
||||
```
|
||||
Wymaga lokalnie `yt-dlp` i `ffmpeg` w PATH (w produkcji są w obrazie).
|
||||
|
||||
## Wdrożenie
|
||||
|
||||
Push do `main` → runner Gitea (`.gitea/workflows/deploy.yml`) buduje obrazy z
|
||||
`docker-compose.prod.yml` i robi `up -d` na demonie Dockera serwera. Konfiguracja
|
||||
reverse proxy + SSL — patrz [DEPLOY.md](DEPLOY.md).
|
||||
27
apps/api/Dockerfile
Normal file
27
apps/api/Dockerfile
Normal 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
8
apps/api/nest-cli.json
Normal 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
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
30
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
17
apps/api/tsconfig.json
Normal file
17
apps/api/tsconfig.json
Normal 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
13
apps/web/Dockerfile
Normal 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
12
apps/web/index.html
Normal 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 & 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
25
apps/web/nginx.conf
Normal 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
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
23
apps/web/package.json
Normal 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
335
apps/web/src/App.tsx
Normal 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 & 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
10
apps/web/src/main.tsx
Normal 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
136
apps/web/src/styles.css
Normal 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
19
apps/web/tsconfig.json
Normal 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
15
apps/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
36
docker-compose.prod.yml
Normal file
36
docker-compose.prod.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: videodownloader
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
image: vsd-api:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "3000"
|
||||
APP_VERSION: "6.5.6"
|
||||
CORS_ORIGIN: ${PUBLIC_BASE_URL:-https://videodownloader.naurecki.pl}
|
||||
WORK_DIR: /app/work
|
||||
# Banner trybu testowego (kosmetyczny) — ustaw TRIAL_ENABLED=1 aby pokazać
|
||||
TRIAL_ENABLED: ${TRIAL_ENABLED:-0}
|
||||
TRIAL_DAYS_LEFT: ${TRIAL_DAYS_LEFT:-}
|
||||
TRIAL_EXPIRES: ${TRIAL_EXPIRES:-}
|
||||
tmpfs:
|
||||
# katalog roboczy pobierań w pamięci/efemeryczny — sprzątany po jobie
|
||||
- /app/work
|
||||
# tylko loopback hosta — diagnostyka; publiczny ruch idzie przez web (nginx)
|
||||
ports:
|
||||
- "127.0.0.1:3105:3000"
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/web/Dockerfile
|
||||
image: vsd-web:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
# publiczny ruch (HTML + statyki + /api) wchodzi tu; reverse proxy hosta → 8085
|
||||
ports:
|
||||
- "127.0.0.1:8085:80"
|
||||
Reference in New Issue
Block a user