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
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:
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(' ')}` });
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user