download job: collapse to one phase (no metadata prefetch)
The job's two phases were a misread — the "count" phase is the client-side
playlist expansion (for the inline-vs-job decision, already done in the panel).
The job itself is just one download request per item.
Dropped the in-job metadata pass entirely:
- reclip-client: reclipDownloadOne no longer prefetches /api/info for a title —
ReClip names the file from the video title itself, so it's a single request
per item.
- execute-download: one phase — expand the playlist, then /api/download each url,
skip failures. Progress is a single { done, failed, total, current } counter
(no meta/dl split); ~2× faster and downloads start right after expansion.
- UI (DownloadJobDetail + panel JobView): one "Downloaded" bar instead of two.
Verified: every item is attempted directly (no /api/info gate), skip-on-error
counts correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,11 @@ import { toast } from 'sonner';
|
|||||||
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
|
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
|
||||||
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
|
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
|
||||||
|
|
||||||
type Counts = { done: number; failed: number; total: number };
|
|
||||||
type DownloadProgress = {
|
type DownloadProgress = {
|
||||||
phase: 'expanding' | 'metadata' | 'download' | 'done';
|
phase: 'expanding' | 'download' | 'done';
|
||||||
meta: Counts;
|
done: number;
|
||||||
dl: Counts;
|
failed: number;
|
||||||
|
total: number;
|
||||||
current?: string;
|
current?: string;
|
||||||
};
|
};
|
||||||
type DownloadJob = {
|
type DownloadJob = {
|
||||||
@@ -54,46 +54,24 @@ const statusBadge = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// A labelled progress bar: fill = processed/total; the caption states saved vs skipped/failed.
|
// The download progress bar: fill = processed/total; caption states saved vs skipped.
|
||||||
const PhaseBar = ({
|
const DownloadBar = ({ p }: { p: DownloadProgress }) => {
|
||||||
label,
|
const processed = p.done + p.failed;
|
||||||
c,
|
const pct = p.total ? (processed / p.total) * 100 : 0;
|
||||||
active,
|
|
||||||
savedLabel,
|
|
||||||
failedLabel,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
c: Counts;
|
|
||||||
active: boolean;
|
|
||||||
savedLabel: string;
|
|
||||||
failedLabel: string;
|
|
||||||
}) => {
|
|
||||||
const processed = c.done + c.failed;
|
|
||||||
const pct = c.total ? (processed / c.total) * 100 : 0;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span
|
<span className="font-medium text-duck-dark dark:text-foreground">Downloaded</span>
|
||||||
className={`font-medium ${active ? 'text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50'}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="tabular-nums text-duck-dark/50 dark:text-foreground/50">
|
<span className="tabular-nums text-duck-dark/50 dark:text-foreground/50">
|
||||||
{processed}/{c.total || '—'}
|
{processed}/{p.total || '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-duck-dark/10 dark:bg-foreground/10">
|
<div className="h-2.5 overflow-hidden rounded-full bg-duck-dark/10 dark:bg-foreground/10">
|
||||||
<div className="h-full rounded-full bg-duck-teal transition-all duration-300" style={{ width: `${pct}%` }} />
|
<div className="h-full rounded-full bg-duck-teal transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 text-[11px] text-duck-dark/50 dark:text-foreground/50">
|
<div className="flex gap-3 text-[11px] text-duck-dark/50 dark:text-foreground/50">
|
||||||
<span className="text-duck-teal">
|
<span className="text-duck-teal">{p.done} saved</span>
|
||||||
{c.done} {savedLabel}
|
{p.failed > 0 && <span className="text-red-500/80">{p.failed} skipped</span>}
|
||||||
</span>
|
|
||||||
{c.failed > 0 && (
|
|
||||||
<span className="text-red-500/80">
|
|
||||||
{c.failed} {failedLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -161,13 +139,7 @@ export const DownloadJobDetail = () => {
|
|||||||
const p = job.progress;
|
const p = job.progress;
|
||||||
const isAudio = job.inputs?.format !== 'video';
|
const isAudio = job.inputs?.format !== 'video';
|
||||||
const phaseLabel =
|
const phaseLabel =
|
||||||
!p || p.phase === 'expanding'
|
!p || p.phase === 'expanding' ? 'Expanding playlist…' : p.phase === 'download' ? 'Downloading' : 'Done';
|
||||||
? 'Preparing…'
|
|
||||||
: p.phase === 'metadata'
|
|
||||||
? 'Fetching metadata'
|
|
||||||
: p.phase === 'download'
|
|
||||||
? 'Downloading'
|
|
||||||
: 'Done';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
@@ -205,20 +177,7 @@ export const DownloadJobDetail = () => {
|
|||||||
<div className="flex-1 min-h-0 overflow-y-auto p-5">
|
<div className="flex-1 min-h-0 overflow-y-auto p-5">
|
||||||
<div className="mx-auto flex max-w-md flex-col gap-5">
|
<div className="mx-auto flex max-w-md flex-col gap-5">
|
||||||
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
|
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
|
||||||
<PhaseBar
|
<DownloadBar p={p ?? { phase: 'expanding', done: 0, failed: 0, total: 0 }} />
|
||||||
label="Metadata"
|
|
||||||
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
|
|
||||||
active={p?.phase === 'metadata'}
|
|
||||||
savedLabel="found"
|
|
||||||
failedLabel="skipped"
|
|
||||||
/>
|
|
||||||
<PhaseBar
|
|
||||||
label="Download"
|
|
||||||
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
|
|
||||||
active={p?.phase === 'download'}
|
|
||||||
savedLabel="saved"
|
|
||||||
failedLabel="failed"
|
|
||||||
/>
|
|
||||||
{running && p?.phase === 'download' && p.current && (
|
{running && p?.phase === 'download' && p.current && (
|
||||||
<div className="truncate text-xs text-duck-dark/50 dark:text-foreground/50" title={p.current}>
|
<div className="truncate text-xs text-duck-dark/50 dark:text-foreground/50" title={p.current}>
|
||||||
<Loader2 className="mr-1.5 inline h-3 w-3 animate-spin" />
|
<Loader2 className="mr-1.5 inline h-3 w-3 animate-spin" />
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { reclipInfo, reclipPlaylist, reclipDownloadOne, type ReclipInfo } from '../../reclip-client';
|
import { reclipPlaylist, reclipDownloadOne } from '../../reclip-client';
|
||||||
|
|
||||||
// The download-job executor — pure scripting, no agent. Two phases:
|
// The download-job executor — pure scripting, no agent. One phase: expand the playlist, then one download
|
||||||
// 1. metadata — expand the playlist, fetch each item's info sequentially, keep the ones that resolve
|
// request per item in the chosen format (audio/video), skipping anything that fails (private / deleted /
|
||||||
// (skip the errors: private/deleted/unavailable).
|
// download error). No metadata prefetch — ReClip names the file from the video title itself. Emits a
|
||||||
// 2. download — download every survivor in the chosen format (audio/video); skip anything that fails.
|
// compact `download:progress` snapshot (counters, not per-item events — a playlist can be thousands of
|
||||||
// Emits a compact `download:progress` snapshot (counters, not per-item events — a playlist can be
|
// items). Throws on abort or a fatal error (playlist expansion); per-item errors are counted + skipped.
|
||||||
// thousands of items). Throws on abort or a fatal error (playlist expansion) so the job manager marks it
|
|
||||||
// stopped/failed; per-item errors are counted and skipped, never fatal.
|
|
||||||
|
|
||||||
type Counts = { done: number; failed: number; total: number };
|
|
||||||
export type DownloadProgress = {
|
export type DownloadProgress = {
|
||||||
phase: 'expanding' | 'metadata' | 'download' | 'done';
|
phase: 'expanding' | 'download' | 'done';
|
||||||
meta: Counts;
|
done: number; // downloaded successfully
|
||||||
dl: Counts;
|
failed: number; // skipped (unavailable / download error)
|
||||||
current?: string; // title of the item currently downloading
|
total: number;
|
||||||
|
current?: string; // url of the item currently downloading
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DownloadEvent = { type: 'download:progress'; progress: DownloadProgress };
|
export type DownloadEvent = { type: 'download:progress'; progress: DownloadProgress };
|
||||||
@@ -36,20 +34,13 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise<vo
|
|||||||
if (!url || !absDir) throw new Error('download job missing url or target directory');
|
if (!url || !absDir) throw new Error('download job missing url or target directory');
|
||||||
const audioOnly = format !== 'video'; // default to audio
|
const audioOnly = format !== 'video'; // default to audio
|
||||||
|
|
||||||
const progress: DownloadProgress = {
|
const progress: DownloadProgress = { phase: 'expanding', done: 0, failed: 0, total: 0 };
|
||||||
phase: 'expanding',
|
|
||||||
meta: { done: 0, failed: 0, total: 0 },
|
|
||||||
dl: { done: 0, failed: 0, total: 0 },
|
|
||||||
};
|
|
||||||
let lastEmit = 0;
|
let lastEmit = 0;
|
||||||
const emit = (force = false) => {
|
const emit = (force = false) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!force && now - lastEmit < EMIT_THROTTLE_MS) return;
|
if (!force && now - lastEmit < EMIT_THROTTLE_MS) return;
|
||||||
lastEmit = now;
|
lastEmit = now;
|
||||||
params.emit({
|
params.emit({ type: 'download:progress', progress: { ...progress } });
|
||||||
type: 'download:progress',
|
|
||||||
progress: { ...progress, meta: { ...progress.meta }, dl: { ...progress.dl } },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const checkAbort = () => {
|
const checkAbort = () => {
|
||||||
if (params.abortSignal.aborted) throw new Error('aborted');
|
if (params.abortSignal.aborted) throw new Error('aborted');
|
||||||
@@ -65,44 +56,20 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise<vo
|
|||||||
if (pl.urls?.length) urls = pl.urls;
|
if (pl.urls?.length) urls = pl.urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 1: metadata (keep survivors) ──
|
// ── Download: one request per item, skip failures ──
|
||||||
progress.phase = 'metadata';
|
progress.phase = 'download';
|
||||||
progress.meta.total = urls.length;
|
progress.total = urls.length;
|
||||||
emit(true);
|
emit(true);
|
||||||
const valid: Array<{ url: string; title: string }> = [];
|
|
||||||
for (const u of urls) {
|
for (const u of urls) {
|
||||||
checkAbort();
|
checkAbort();
|
||||||
const info = await reclipInfo(u).catch((): ReclipInfo => ({ error: 'fetch failed' }));
|
progress.current = u;
|
||||||
if (info && !info.error) {
|
|
||||||
valid.push({ url: u, title: info.title ?? '' });
|
|
||||||
progress.meta.done++; // done = kept; failed = skipped (both phases read `(done+failed)/total`)
|
|
||||||
} else {
|
|
||||||
progress.meta.failed++;
|
|
||||||
}
|
|
||||||
emit();
|
|
||||||
}
|
|
||||||
emit(true);
|
|
||||||
|
|
||||||
// ── Phase 2: download survivors ──
|
|
||||||
progress.phase = 'download';
|
|
||||||
progress.dl.total = valid.length;
|
|
||||||
emit(true);
|
|
||||||
for (const item of valid) {
|
|
||||||
checkAbort();
|
|
||||||
progress.current = item.title || item.url;
|
|
||||||
emit(true);
|
emit(true);
|
||||||
try {
|
try {
|
||||||
await reclipDownloadOne({
|
await reclipDownloadOne({ url: u, destDir: absDir, audioOnly, signal: params.abortSignal });
|
||||||
url: item.url,
|
progress.done++;
|
||||||
destDir: absDir,
|
|
||||||
audioOnly,
|
|
||||||
title: item.title,
|
|
||||||
signal: params.abortSignal,
|
|
||||||
});
|
|
||||||
progress.dl.done++;
|
|
||||||
} catch {
|
} catch {
|
||||||
checkAbort(); // an abort surfaces as a throw here — re-check so it stops instead of counting as a skip
|
checkAbort(); // an abort surfaces as a throw here — re-check so it stops instead of counting as a skip
|
||||||
progress.dl.failed++;
|
progress.failed++;
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type DownloadOpts = {
|
|||||||
url: string;
|
url: string;
|
||||||
destDir: string; // absolute directory to write the finished file into
|
destDir: string; // absolute directory to write the finished file into
|
||||||
audioOnly: boolean;
|
audioOnly: boolean;
|
||||||
title?: string; // for a title-based filename (ReClip names by job id otherwise)
|
title?: string; // optional override; omit and ReClip names the file from the video title itself
|
||||||
onPhase?: (phase: 'transferring') => void;
|
onPhase?: (phase: 'transferring') => void;
|
||||||
signal?: { aborted: boolean };
|
signal?: { aborted: boolean };
|
||||||
};
|
};
|
||||||
@@ -53,22 +53,17 @@ type DownloadOpts = {
|
|||||||
/**
|
/**
|
||||||
* Download ONE video/audio via ReClip and stream the finished file into `destDir`. Resolves with the
|
* Download ONE video/audio via ReClip and stream the finished file into `destDir`. Resolves with the
|
||||||
* saved filename; throws on any failure (unreachable / rejected / job error / timeout / abort). Respects
|
* saved filename; throws on any failure (unreachable / rejected / job error / timeout / abort). Respects
|
||||||
* a cooperative `signal.aborted` between polls and while streaming.
|
* a cooperative `signal.aborted` between polls and while streaming. No metadata prefetch — ReClip derives
|
||||||
|
* the filename from the video title — so this is a single request per item.
|
||||||
*/
|
*/
|
||||||
export async function reclipDownloadOne(opts: DownloadOpts): Promise<string> {
|
export async function reclipDownloadOne(opts: DownloadOpts): Promise<string> {
|
||||||
const { url, destDir, audioOnly, signal } = opts;
|
const { url, destDir, audioOnly, signal } = opts;
|
||||||
const aborted = () => signal?.aborted === true;
|
const aborted = () => signal?.aborted === true;
|
||||||
|
|
||||||
let title = opts.title ?? '';
|
|
||||||
if (!title) {
|
|
||||||
const info = await reclipInfo(url).catch(() => null);
|
|
||||||
title = info?.title ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const dlRes = await fetch(`${RECLIP_BASE}/api/download`, {
|
const dlRes = await fetch(`${RECLIP_BASE}/api/download`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url, format: audioOnly ? 'audio' : 'video', title }),
|
body: JSON.stringify({ url, format: audioOnly ? 'audio' : 'video', title: opts.title ?? '' }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
throw new Error(`Could not reach ReClip at ${RECLIP_BASE}`);
|
throw new Error(`Could not reach ReClip at ${RECLIP_BASE}`);
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI';
|
import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI';
|
||||||
|
|
||||||
// Compact progress shape mirrored from the download-job executor (done = successes, failed = skipped).
|
// Compact progress shape mirrored from the download-job executor (done = saved, failed = skipped).
|
||||||
type JobCounts = { done: number; failed: number; total: number };
|
|
||||||
type JobProgress = {
|
type JobProgress = {
|
||||||
phase: 'expanding' | 'metadata' | 'download' | 'done';
|
phase: 'expanding' | 'download' | 'done';
|
||||||
meta: JobCounts;
|
done: number;
|
||||||
dl: JobCounts;
|
failed: number;
|
||||||
|
total: number;
|
||||||
current?: string;
|
current?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,8 +275,8 @@ export const VideoDownloadPanel = () => {
|
|||||||
if (cancelled || !j) return;
|
if (cancelled || !j) return;
|
||||||
setJobStatus(j.status);
|
setJobStatus(j.status);
|
||||||
setJobProg(j.progress);
|
setJobProg(j.progress);
|
||||||
if (j.progress && j.progress.dl.done > lastDlDone.current) {
|
if (j.progress && j.progress.done > lastDlDone.current) {
|
||||||
lastDlDone.current = j.progress.dl.done;
|
lastDlDone.current = j.progress.done;
|
||||||
setRefreshSignal((n) => n + 1);
|
setRefreshSignal((n) => n + 1);
|
||||||
}
|
}
|
||||||
if (['completed', 'failed', 'stopped', 'interrupted'].includes(j.status)) clearInterval(timer);
|
if (['completed', 'failed', 'stopped', 'interrupted'].includes(j.status)) clearInterval(timer);
|
||||||
@@ -555,8 +555,6 @@ export const VideoDownloadPanel = () => {
|
|||||||
|
|
||||||
// ── Job progress view ──
|
// ── Job progress view ──
|
||||||
|
|
||||||
const jobPct = (c: JobCounts) => (c.total ? ((c.done + c.failed) / c.total) * 100 : 0);
|
|
||||||
|
|
||||||
const JobView = ({
|
const JobView = ({
|
||||||
prog,
|
prog,
|
||||||
status,
|
status,
|
||||||
@@ -572,39 +570,11 @@ const JobView = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const terminal = ['completed', 'failed', 'stopped', 'interrupted'].includes(status);
|
const terminal = ['completed', 'failed', 'stopped', 'interrupted'].includes(status);
|
||||||
const phaseLabel =
|
const phaseLabel =
|
||||||
!prog || prog.phase === 'expanding'
|
!prog || prog.phase === 'expanding' ? 'Expanding playlist…' : prog.phase === 'download' ? 'Downloading' : 'Done';
|
||||||
? 'Preparing…'
|
const done = prog?.done ?? 0;
|
||||||
: prog.phase === 'metadata'
|
const failed = prog?.failed ?? 0;
|
||||||
? 'Fetching metadata'
|
const total = prog?.total ?? 0;
|
||||||
: prog.phase === 'download'
|
const pct = total ? ((done + failed) / total) * 100 : 0;
|
||||||
? 'Downloading'
|
|
||||||
: 'Done';
|
|
||||||
const Bar = ({ label, c, saved, failed }: { label: string; c: JobCounts; saved: string; failed: string }) => (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="font-medium text-duck-dark">{label}</span>
|
|
||||||
<span className="tabular-nums text-duck-dark/50">
|
|
||||||
{c.done + c.failed}/{c.total || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-duck-dark/10">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-duck-teal transition-all duration-300"
|
|
||||||
style={{ width: `${jobPct(c)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 text-[10px] text-duck-dark/50">
|
|
||||||
<span className="text-duck-teal">
|
|
||||||
{c.done} {saved}
|
|
||||||
</span>
|
|
||||||
{c.failed > 0 && (
|
|
||||||
<span className="text-red-500/80">
|
|
||||||
{c.failed} {failed}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
@@ -622,8 +592,21 @@ const JobView = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-xs text-duck-dark/40">{audio ? 'Audio' : 'Video'}</span>
|
<span className="ml-auto text-xs text-duck-dark/40">{audio ? 'Audio' : 'Video'}</span>
|
||||||
</div>
|
</div>
|
||||||
<Bar label="Metadata" c={prog?.meta ?? { done: 0, failed: 0, total: 0 }} saved="found" failed="skipped" />
|
<div className="flex flex-col gap-1">
|
||||||
<Bar label="Download" c={prog?.dl ?? { done: 0, failed: 0, total: 0 }} saved="saved" failed="failed" />
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="font-medium text-duck-dark">Downloaded</span>
|
||||||
|
<span className="tabular-nums text-duck-dark/50">
|
||||||
|
{done + failed}/{total || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 overflow-hidden rounded-full bg-duck-dark/10">
|
||||||
|
<div className="h-full rounded-full bg-duck-teal transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-[10px] text-duck-dark/50">
|
||||||
|
<span className="text-duck-teal">{done} saved</span>
|
||||||
|
{failed > 0 && <span className="text-red-500/80">{failed} skipped</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!terminal && prog?.phase === 'download' && prog.current && (
|
{!terminal && prog?.phase === 'download' && prog.current && (
|
||||||
<p className="truncate text-xs text-duck-dark/50" title={prog.current}>
|
<p className="truncate text-xs text-duck-dark/50" title={prog.current}>
|
||||||
{prog.current}
|
{prog.current}
|
||||||
|
|||||||
Reference in New Issue
Block a user