fix: SSE bez nazwanych zdarzen (postep stal na 0); usun tryb testowy; opcja napisow (osadz/.srt)
All checks were successful
deploy / deploy (push) Successful in 28s

- jobs.controller: usun pole type z MessageEvent — onmessage w EventSource
  reaguje tylko na domyslne 'message', przez co front nie dostawal zdarzen
- usun trial (health, compose, deploy.yml, UI, zmienne Gitea)
- napisy: wybor osadzania w wideo lub osobnego pliku .srt (--convert-subs srt)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:22:12 +02:00
parent a16bb14298
commit 94278f119d
11 changed files with 46 additions and 48 deletions

View File

@@ -26,9 +26,6 @@ jobs:
- name: Start stacka - name: Start stacka
env: env:
PUBLIC_BASE_URL: https://videodownloader.naurecki.pl 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 run: docker compose -f docker-compose.prod.yml up -d
- name: Czekaj na gotowość API - name: Czekaj na gotowość API

View File

@@ -33,6 +33,3 @@ sudo certbot --apache -d videodownloader.naurecki.pl --redirect -n \
DNS: `*.naurecki.pl` (wildcard) już wskazuje serwer — bez dodatkowej konfiguracji. 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`.

View File

@@ -42,7 +42,6 @@ Ruch publiczny: `Apache (vhost videodownloader.naurecki.pl, SSL)` → `127.0.0.1
zgodnie z oryginałem **nie** dotyczy automatycznie generowanych napisów YouTube. zgodnie z oryginałem **nie** dotyczy automatycznie generowanych napisów YouTube.
- Duży przycisk **POBIERZ** + **STOP** (przerwanie joba). - Duży przycisk **POBIERZ** + **STOP** (przerwanie joba).
- **Log na żywo** (SSE) — komunikaty postępu, konwersji i scalania audio/wideo. - **Log na żywo** (SSE) — komunikaty postępu, konwersji i scalania audio/wideo.
- Banner **trybu testowego** (kosmetyczny, sterowany zmienną `TRIAL_*`).
- Przyciski **Wsparcie** / **O mnie**. - Przyciski **Wsparcie** / **O mnie**.
> Różnica względem wersji desktop: pole „Zapisz do” (ścieżka dyskowa Windows) w > Różnica względem wersji desktop: pole „Zapisz do” (ścieżka dyskowa Windows) w

View File

@@ -8,11 +8,6 @@ export class HealthController {
ok: true, ok: true,
service: 'vsd-api', service: 'vsd-api',
version: process.env.APP_VERSION || '6.5.6', 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,
},
}; };
} }
} }

View File

@@ -28,4 +28,9 @@ export class CreateJobDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
subtitles?: boolean; subtitles?: boolean;
// 'embed' = osadź napisy w pliku wideo; 'file' = zapisz jako osobny plik .srt
@IsOptional()
@IsIn(['embed', 'file'])
subsMode?: 'embed' | 'file';
} }

View File

@@ -30,8 +30,10 @@ export class JobsController {
@Sse(':id/events') @Sse(':id/events')
events(@Param('id') id: string): Observable<MessageEvent> { events(@Param('id') id: string): Observable<MessageEvent> {
// Bez pola `type` — inaczej Nest emituje nazwane zdarzenia (event: <kind>),
// a przeglądarkowy EventSource.onmessage reaguje tylko na domyślne „message”.
return this.jobs.events(id).pipe( return this.jobs.events(id).pipe(
map((ev) => ({ data: JSON.stringify(ev), type: ev.kind }) as MessageEvent), map((ev) => ({ data: JSON.stringify(ev) }) as MessageEvent),
); );
} }

View File

@@ -67,7 +67,14 @@ export class JobsService {
private async run(job: Job, dto: CreateJobDto) { private async run(job: Job, dto: CreateJobDto) {
const outTemplate = path.join(job.workDir, '%(playlist_index&{} - |)s%(title)s.%(ext)s'); 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); const args = buildArgs(
dto.mode,
dto.url,
dto.format,
!!dto.subtitles,
dto.subsMode || 'embed',
outTemplate,
);
this.emit(job, { kind: 'status', status: 'running' }); this.emit(job, { kind: 'status', status: 'running' });
this.emit(job, { kind: 'log', line: `$ yt-dlp ${args.join(' ')}` }); this.emit(job, { kind: 'log', line: `$ yt-dlp ${args.join(' ')}` });

View File

@@ -12,6 +12,7 @@ export function buildArgs(
url: string, url: string,
format: string | undefined, format: string | undefined,
subtitles: boolean, subtitles: boolean,
subsMode: 'embed' | 'file',
outTemplate: string, outTemplate: string,
): string[] { ): string[] {
const base = [ const base = [
@@ -25,8 +26,11 @@ export function buildArgs(
]; ];
// Napisy: tylko realne (bez automatycznie generowanych przez YouTube). // Napisy: tylko realne (bez automatycznie generowanych przez YouTube).
// embed -> wtopione w plik wideo; file -> osobny plik .srt obok wideo (do ZIP).
const subs = subtitles const subs = subtitles
? ['--write-subs', '--no-write-auto-subs', '--sub-langs', 'all', '--embed-subs'] ? subsMode === 'file'
? ['--write-subs', '--no-write-auto-subs', '--sub-langs', 'all', '--convert-subs', 'srt']
: ['--write-subs', '--no-write-auto-subs', '--sub-langs', 'all', '--embed-subs']
: []; : [];
switch (mode) { switch (mode) {

View File

@@ -79,10 +79,10 @@ export default function App() {
const [mode, setMode] = useState<Mode>('mp3'); const [mode, setMode] = useState<Mode>('mp3');
const [format, setFormat] = useState('320'); const [format, setFormat] = useState('320');
const [subtitles, setSubtitles] = useState(false); const [subtitles, setSubtitles] = useState(false);
const [subsMode, setSubsMode] = useState<'embed' | 'file'>('embed');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [log, setLog] = useState<string[]>(['OK: wszystko gotowe do pobierania.']); 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 esRef = useRef<EventSource | null>(null);
const jobRef = useRef<string | null>(null); const jobRef = useRef<string | null>(null);
const logEndRef = useRef<HTMLDivElement | null>(null); const logEndRef = useRef<HTMLDivElement | null>(null);
@@ -94,15 +94,6 @@ export default function App() {
setFormat(MODES.find((m) => m.key === mode)!.formats[0].value); setFormat(MODES.find((m) => m.key === mode)!.formats[0].value);
}, [mode]); }, [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(() => { useEffect(() => {
logEndRef.current?.scrollIntoView({ block: 'end' }); logEndRef.current?.scrollIntoView({ block: 'end' });
}, [log]); }, [log]);
@@ -139,7 +130,7 @@ export default function App() {
const res = await fetch('/api/jobs', { const res = await fetch('/api/jobs', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim(), mode, format, subtitles }), body: JSON.stringify({ url: url.trim(), mode, format, subtitles, subsMode }),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { id } = await res.json(); const { id } = await res.json();
@@ -262,21 +253,34 @@ export default function App() {
</select> </select>
</div> </div>
<div className="col"> <div className="col">
<label className="field-label">Zapisz do:</label> <label className="field-label">Napisy:</label>
<div className="save-to"> <div className="save-to">
<span>Pobranie przez przeglądarkę (folder Pobrane)</span>
<label className="subs"> <label className="subs">
<input <input
type="checkbox" type="checkbox"
checked={subtitles} checked={subtitles && mode !== 'mp3'}
onChange={(e) => setSubtitles(e.target.checked)} onChange={(e) => setSubtitles(e.target.checked)}
disabled={mode === 'mp3'} disabled={mode === 'mp3'}
/> />
Napisy Pobierz napisy
</label> </label>
<select
className="select subs-select"
value={subsMode}
onChange={(e) => setSubsMode(e.target.value as 'embed' | 'file')}
disabled={!subtitles || mode === 'mp3'}
>
<option value="embed">Osadź w pliku wideo</option>
<option value="file">Osobny plik (.srt)</option>
</select>
</div> </div>
</div> </div>
</div> </div>
<p className="hint">
Napisy dotyczą tylko faktycznie dostępnych napisów nie obejmują
automatycznie generowanych napisów YouTube. Gotowy plik pobiera się przez
przeglądarkę (folder Pobrane).
</p>
<div className="actions"> <div className="actions">
<button <button
@@ -298,12 +302,6 @@ export default function App() {
</div> </div>
)} )}
{trial && (
<div className="trial">
Tryb testowy: zostało {trial.daysLeft} dni. Wygasa: {trial.expires}.
</div>
)}
<div className="logbox"> <div className="logbox">
<div className="logbox-title">Log</div> <div className="logbox-title">Log</div>
<div className="logbox-body"> <div className="logbox-body">

View File

@@ -89,10 +89,13 @@ body {
.select:focus { outline: none; border-color: var(--blue-bright); } .select:focus { outline: none; border-color: var(--blue-bright); }
.save-to { .save-to {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 14px; color: var(--muted); font-size: 13px; padding: 8px 12px; color: var(--muted); font-size: 13px;
display: flex; align-items: center; justify-content: space-between; gap: 10px; display: flex; align-items: center; gap: 12px;
} }
.subs { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 13px; cursor: pointer; } .subs { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 13px; cursor: pointer; white-space: nowrap; }
.subs-select { flex: 1; padding: 9px 10px; font-size: 13px; }
.subs-select:disabled { opacity: .5; }
.hint { color: var(--muted); font-size: 12px; margin: 8px 2px 0; line-height: 1.5; }
.actions { display: flex; gap: 12px; margin-top: 18px; } .actions { display: flex; gap: 12px; margin-top: 18px; }
.download-btn { .download-btn {
@@ -109,11 +112,6 @@ body {
.progress { margin-top: 12px; height: 6px; background: var(--panel); border-radius: 4px; overflow: hidden; } .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; } .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 { 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-title { font-weight: 700; margin-bottom: 8px; }
.logbox-body { .logbox-body {

View File

@@ -12,10 +12,6 @@ services:
APP_VERSION: "6.5.6" APP_VERSION: "6.5.6"
CORS_ORIGIN: ${PUBLIC_BASE_URL:-https://videodownloader.naurecki.pl} CORS_ORIGIN: ${PUBLIC_BASE_URL:-https://videodownloader.naurecki.pl}
WORK_DIR: /app/work 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: tmpfs:
# katalog roboczy pobierań w pamięci/efemeryczny — sprzątany po jobie # katalog roboczy pobierań w pamięci/efemeryczny — sprzątany po jobie
- /app/work - /app/work