Batch upload Suno MP3s with metadata
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name ChatGPT to Suno Prompt Copier
|
// @name ChatGPT to Suno Prompt Copier
|
||||||
// @namespace local.suno-helper
|
// @namespace local.suno-helper
|
||||||
// @version 0.8.0
|
// @version 0.8.1
|
||||||
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
||||||
// @author local
|
// @author local
|
||||||
// @match https://chatgpt.com/*
|
// @match https://chatgpt.com/*
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const SCRIPT_VERSION = "0.8.0";
|
const SCRIPT_VERSION = "0.8.1";
|
||||||
const STORAGE_KEY = "chatgptSunoPromptSlots";
|
const STORAGE_KEY = "chatgptSunoPromptSlots";
|
||||||
const SLOT_COUNT = 2;
|
const SLOT_COUNT = 2;
|
||||||
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
|
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
|
||||||
@@ -1095,6 +1095,52 @@
|
|||||||
throw new Error("브라우저도 WAV 다운로드를 못 받았어");
|
throw new Error("브라우저도 WAV 다운로드를 못 받았어");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMp3Candidates(track) {
|
||||||
|
const candidates = [];
|
||||||
|
if (track.audioUrl && !/\.wav(?:\?|$)/i.test(track.audioUrl)) candidates.push(track.audioUrl);
|
||||||
|
const metadata = track.metadata || {};
|
||||||
|
for (const value of [
|
||||||
|
metadata.audio_url,
|
||||||
|
metadata.audioUrl,
|
||||||
|
metadata.stream_audio_url,
|
||||||
|
metadata.streamAudioUrl,
|
||||||
|
metadata.play_url,
|
||||||
|
metadata.playUrl,
|
||||||
|
metadata.play_path,
|
||||||
|
metadata.playPath,
|
||||||
|
metadata.mp3_url,
|
||||||
|
metadata.mp3Url,
|
||||||
|
]) {
|
||||||
|
const normalized = normalizeSunoMediaUrl(value);
|
||||||
|
if (normalized && !/\.wav(?:\?|$)/i.test(normalized)) candidates.push(normalized);
|
||||||
|
}
|
||||||
|
if (track.id) {
|
||||||
|
candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.mp3`);
|
||||||
|
candidates.push(`https://cdn2.suno.ai/${encodeURIComponent(track.id)}.mp3`);
|
||||||
|
}
|
||||||
|
return unique(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSunoMp3Buffer(track, statusTarget) {
|
||||||
|
const candidates = getMp3Candidates(track);
|
||||||
|
if (!candidates.length) throw new Error("MP3 후보 URL 없음");
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
setStatus(statusTarget, `브라우저에서 MP3 받는 중: ${track.title || track.id}`);
|
||||||
|
const response = await callTampermonkeyBytes(url, { method: "GET" });
|
||||||
|
const size = response.buffer?.byteLength || 0;
|
||||||
|
if (response.ok && size > 128 * 1024) {
|
||||||
|
track.audioUrl = url;
|
||||||
|
return response.buffer;
|
||||||
|
}
|
||||||
|
console.warn("[ChatGPT to Suno] MP3 bytes not ready", url, response.status, size);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ChatGPT to Suno] MP3 download failed", url, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("브라우저도 MP3 다운로드를 못 받았어");
|
||||||
|
}
|
||||||
|
|
||||||
function downloadTextFile(text, name) {
|
function downloadTextFile(text, name) {
|
||||||
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1247,14 +1293,17 @@
|
|||||||
setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. 로컬 프로그램이 WAV 준비를 기다리며 처리 중.`);
|
setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. 로컬 프로그램이 WAV 준비를 기다리며 처리 중.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadTrackToCompanion(track, statusTarget) {
|
async function uploadTrackToCompanion(track, statusTarget, format = "mp3") {
|
||||||
const buffer = await downloadSunoWavBuffer(track, statusTarget);
|
const buffer = format === "wav"
|
||||||
setStatus(statusTarget, `로컬 프로그램으로 WAV 업로드 중: ${track.title || track.id}`);
|
? await downloadSunoWavBuffer(track, statusTarget)
|
||||||
|
: await downloadSunoMp3Buffer(track, statusTarget);
|
||||||
|
setStatus(statusTarget, `로컬 프로그램으로 ${format.toUpperCase()} 업로드 중: ${track.title || track.id}`);
|
||||||
const payload = {
|
const payload = {
|
||||||
pageUrl: location.href,
|
pageUrl: location.href,
|
||||||
capturedAt: new Date().toISOString(),
|
capturedAt: new Date().toISOString(),
|
||||||
track,
|
track,
|
||||||
wavBase64: arrayBufferToBase64(buffer),
|
audioExt: format,
|
||||||
|
audioBase64: arrayBufferToBase64(buffer),
|
||||||
};
|
};
|
||||||
const response = await fetch("http://127.0.0.1:17873/upload-track", {
|
const response = await fetch("http://127.0.0.1:17873/upload-track", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1268,6 +1317,50 @@
|
|||||||
return tryParseJson(text) || {};
|
return tryParseJson(text) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadTracksToCompanion(tracks, statusTarget, format = "mp3") {
|
||||||
|
if (!tracks.length) {
|
||||||
|
setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const syncManifest = {
|
||||||
|
pageUrl: location.href,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
count: tracks.length,
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
setStatus(statusTarget, `${tracks.length}곡 로컬 폴더와 비교 중...`);
|
||||||
|
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(syncManifest),
|
||||||
|
});
|
||||||
|
const syncText = await syncResponse.text();
|
||||||
|
if (!syncResponse.ok) throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
|
||||||
|
const syncData = tryParseJson(syncText) || {};
|
||||||
|
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
|
||||||
|
if (!missingTracks.length) {
|
||||||
|
setStatus(statusTarget, `동기화 완료 상태: ${tracks.length}곡 모두 로컬에 있음`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (let index = 0; index < missingTracks.length; index += 1) {
|
||||||
|
const track = { ...missingTracks[index] };
|
||||||
|
try {
|
||||||
|
setStatus(statusTarget, `${index + 1}/${missingTracks.length} ${format.toUpperCase()} 처리 중: ${track.title || track.id}`);
|
||||||
|
await uploadTrackToCompanion(track, statusTarget, format);
|
||||||
|
uploaded += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.warn("[ChatGPT to Suno] upload failed", track, error);
|
||||||
|
setStatus(statusTarget, `${track.title || track.id} 실패: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
|
}
|
||||||
|
setStatus(statusTarget, `${format.toUpperCase()} 일괄 업로드 완료: ${uploaded}곡 성공, ${failed}곡 실패. 로컬에서 메타데이터 후처리 중.`);
|
||||||
|
}
|
||||||
|
|
||||||
function trackOptionLabel(track, index) {
|
function trackOptionLabel(track, index) {
|
||||||
const title = track?.title || track?.id || "제목 없음";
|
const title = track?.title || track?.id || "제목 없음";
|
||||||
const shortId = track?.id ? String(track.id).slice(0, 8) : "";
|
const shortId = track?.id ? String(track.id).slice(0, 8) : "";
|
||||||
@@ -1325,7 +1418,7 @@
|
|||||||
<option value="">감지된 곡 없음</option>
|
<option value="">감지된 곡 없음</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="cgs-row">
|
<div class="cgs-row">
|
||||||
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one>선택 1곡만 다운</button>
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one>선택 1곡 MP3</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cgs-row">
|
<div class="cgs-row">
|
||||||
<button class="cgs-btn cgs-wide" type="button" data-cgs-test-companion>연결 테스트</button>
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-test-companion>연결 테스트</button>
|
||||||
@@ -1334,7 +1427,7 @@
|
|||||||
<button class="cgs-btn cgs-wide" type="button" data-cgs-copy-diagnostics>진단 복사</button>
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-copy-diagnostics>진단 복사</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cgs-row">
|
<div class="cgs-row">
|
||||||
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library>WAV + 메타데이터 다운</button>
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library>MP3 일괄 + 메타데이터</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cgs-status"></div>
|
<div class="cgs-status"></div>
|
||||||
`;
|
`;
|
||||||
@@ -1416,7 +1509,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await uploadTrackToCompanion(track, body);
|
const data = await uploadTrackToCompanion(track, body, "mp3");
|
||||||
setStatus(body, `선택 1곡 업로드 완료: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개 / 처리 ${data.active || "대기"}`);
|
setStatus(body, `선택 1곡 업로드 완료: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개 / 처리 ${data.active || "대기"}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(body, `선택 1곡 처리 실패: ${error.message || error}`);
|
setStatus(body, `선택 1곡 처리 실패: ${error.message || error}`);
|
||||||
@@ -1430,10 +1523,9 @@
|
|||||||
}
|
}
|
||||||
updateSummary();
|
updateSummary();
|
||||||
try {
|
try {
|
||||||
await postToCompanion(libraryTracks, body);
|
await uploadTracksToCompanion(libraryTracks, body, "mp3");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(body, `로컬 프로그램 연결 실패. manifest로 저장할게: ${error.message || error}`);
|
setStatus(body, `MP3 일괄 처리 실패: ${error.message || error}`);
|
||||||
await exportSunoManifest(libraryTracks, body);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -402,7 +402,17 @@ foreach ($track in $tracks) {
|
|||||||
$style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description")
|
$style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description")
|
||||||
$model = Get-DeepValue $track @("model_name", "model", "major_model_version")
|
$model = Get-DeepValue $track @("model_name", "model", "major_model_version")
|
||||||
$createdAt = Get-DeepValue $track @("created_at", "createdAt")
|
$createdAt = Get-DeepValue $track @("created_at", "createdAt")
|
||||||
|
$localAudioPath = Get-DeepValue $track @("localAudioPath", "local_audio_path", "uploadedAudioPath")
|
||||||
|
$localAudioExt = Get-DeepValue $track @("localAudioExt", "local_audio_ext", "audioExt")
|
||||||
$localWavPath = Get-DeepValue $track @("localWavPath", "local_wav_path", "uploadedWavPath")
|
$localWavPath = Get-DeepValue $track @("localWavPath", "local_wav_path", "uploadedWavPath")
|
||||||
|
if (-not $localAudioPath -and $localWavPath) {
|
||||||
|
$localAudioPath = $localWavPath
|
||||||
|
$localAudioExt = "wav"
|
||||||
|
}
|
||||||
|
if (-not $localAudioExt -and $localAudioPath) {
|
||||||
|
$localAudioExt = [IO.Path]::GetExtension($localAudioPath).TrimStart(".").ToLowerInvariant()
|
||||||
|
}
|
||||||
|
$outputExt = if ($localAudioExt -eq "mp3") { "mp3" } else { "flac" }
|
||||||
|
|
||||||
$wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") })
|
$wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") })
|
||||||
$wavCandidates = @()
|
$wavCandidates = @()
|
||||||
@@ -410,26 +420,27 @@ foreach ($track in $tracks) {
|
|||||||
$wavCandidates = $wavCandidates | Where-Object { $_ } | Select-Object -Unique
|
$wavCandidates = $wavCandidates | Where-Object { $_ } | Select-Object -Unique
|
||||||
$imageUrl = if ($track.imageUrl) { "$($track.imageUrl)" } else { Get-DeepValue $track @("image_url", "imageUrl", "image_large_url", "cover_url") }
|
$imageUrl = if ($track.imageUrl) { "$($track.imageUrl)" } else { Get-DeepValue $track @("image_url", "imageUrl", "image_large_url", "cover_url") }
|
||||||
|
|
||||||
$inputAudio = Join-Path $workRoot "$prefix.input.wav"
|
$inputExt = if ($localAudioExt) { $localAudioExt } else { "wav" }
|
||||||
|
$inputAudio = Join-Path $workRoot "$prefix.input.$inputExt"
|
||||||
$rawCoverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')"
|
$rawCoverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')"
|
||||||
$trackCoverFile = Join-Path $workRoot "$prefix.embed-cover.jpg"
|
$trackCoverFile = Join-Path $workRoot "$prefix.embed-cover.jpg"
|
||||||
$coverFile = Join-Path $trackFolder "cover.jpg"
|
$coverFile = Join-Path $trackFolder "cover.jpg"
|
||||||
$metadataFile = Join-Path $trackFolder ("metadata.{0}.json" -f $shortId)
|
$metadataFile = Join-Path $trackFolder ("metadata.{0}.json" -f $shortId)
|
||||||
$lyricsFile = Join-Path $trackFolder ("{0}.lrc" -f $prefix)
|
$lyricsFile = Join-Path $trackFolder ("{0}.lrc" -f $prefix)
|
||||||
$flacFile = Join-Path $trackFolder ("{0}.flac" -f $prefix)
|
$outputFile = Join-Path $trackFolder ("{0}.{1}" -f $prefix, $outputExt)
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "[$index/$($tracks.Count)] $title"
|
Write-Host "[$index/$($tracks.Count)] $title"
|
||||||
|
|
||||||
if (-not $localWavPath -and -not $wavCandidates.Count) {
|
if (-not $localAudioPath -and -not $wavCandidates.Count) {
|
||||||
Write-Warning "WAV URL이 없어서 건너뜀: $title"
|
Write-Warning "오디오 입력이 없어서 건너뜀: $title"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$audioOk = $false
|
$audioOk = $false
|
||||||
if ($localWavPath -and (Test-Path -LiteralPath $localWavPath)) {
|
if ($localAudioPath -and (Test-Path -LiteralPath $localAudioPath)) {
|
||||||
Write-Host "업로드된 WAV 사용: $localWavPath"
|
Write-Host "업로드된 오디오 사용: $localAudioPath"
|
||||||
Copy-Item -LiteralPath $localWavPath -Destination $inputAudio -Force
|
Copy-Item -LiteralPath $localAudioPath -Destination $inputAudio -Force
|
||||||
$audioOk = (Test-Path -LiteralPath $inputAudio) -and ((Get-Item -LiteralPath $inputAudio).Length -gt 0)
|
$audioOk = (Test-Path -LiteralPath $inputAudio) -and ((Get-Item -LiteralPath $inputAudio).Length -gt 0)
|
||||||
} else {
|
} else {
|
||||||
$audioOk = Invoke-DownloadWithRetry $wavCandidates $inputAudio $WavRetrySeconds $WavRetryIntervalSeconds
|
$audioOk = Invoke-DownloadWithRetry $wavCandidates $inputAudio $WavRetrySeconds $WavRetryIntervalSeconds
|
||||||
@@ -481,10 +492,19 @@ foreach ($track in $tracks) {
|
|||||||
$argsList.Add("-metadata:s:v")
|
$argsList.Add("-metadata:s:v")
|
||||||
$argsList.Add("comment=Cover (front)")
|
$argsList.Add("comment=Cover (front)")
|
||||||
}
|
}
|
||||||
|
if ($outputExt -eq "mp3") {
|
||||||
|
$argsList.Add("-c:a")
|
||||||
|
$argsList.Add("copy")
|
||||||
|
$argsList.Add("-id3v2_version")
|
||||||
|
$argsList.Add("3")
|
||||||
|
$argsList.Add("-write_id3v1")
|
||||||
|
$argsList.Add("1")
|
||||||
|
} else {
|
||||||
$argsList.Add("-c:a")
|
$argsList.Add("-c:a")
|
||||||
$argsList.Add("flac")
|
$argsList.Add("flac")
|
||||||
$argsList.Add("-compression_level")
|
$argsList.Add("-compression_level")
|
||||||
$argsList.Add("8")
|
$argsList.Add("8")
|
||||||
|
}
|
||||||
Add-MetadataArg $argsList "TITLE" $title
|
Add-MetadataArg $argsList "TITLE" $title
|
||||||
Add-MetadataArg $argsList "ARTIST" $ArtistName
|
Add-MetadataArg $argsList "ARTIST" $ArtistName
|
||||||
Add-MetadataArg $argsList "ALBUM" $albumTitle
|
Add-MetadataArg $argsList "ALBUM" $albumTitle
|
||||||
@@ -501,15 +521,15 @@ foreach ($track in $tracks) {
|
|||||||
Add-MetadataArg $argsList "SUNO_MODEL" $model
|
Add-MetadataArg $argsList "SUNO_MODEL" $model
|
||||||
Add-MetadataArg $argsList "SUNO_CLIP_ID" $id
|
Add-MetadataArg $argsList "SUNO_CLIP_ID" $id
|
||||||
Add-MetadataArg $argsList "TMED" "Digital Media"
|
Add-MetadataArg $argsList "TMED" "Digital Media"
|
||||||
$argsList.Add($flacFile)
|
$argsList.Add($outputFile)
|
||||||
|
|
||||||
& $ffmpeg @argsList
|
& $ffmpeg @argsList
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Warning "FLAC 변환 실패: $title"
|
Write-Warning "오디오 후처리 실패: $title"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "완료: $flacFile"
|
Write-Host "완료: $outputFile"
|
||||||
Write-AlbumNfo $trackFolder $albumTitle $ArtistName $style $ffprobe
|
Write-AlbumNfo $trackFolder $albumTitle $ArtistName $style $ffprobe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -252,16 +252,21 @@ function Save-UploadedTrack {
|
|||||||
param($Payload)
|
param($Payload)
|
||||||
|
|
||||||
if (-not $Payload.track) { throw "track is required" }
|
if (-not $Payload.track) { throw "track is required" }
|
||||||
if (-not $Payload.wavBase64) { throw "wavBase64 is required" }
|
$audioBase64 = if ($Payload.audioBase64) { "$($Payload.audioBase64)" } else { "$($Payload.wavBase64)" }
|
||||||
|
if (-not $audioBase64) { throw "audioBase64 is required" }
|
||||||
|
|
||||||
$track = $Payload.track
|
$track = $Payload.track
|
||||||
$id = "$($track.id)".Trim()
|
$id = "$($track.id)".Trim()
|
||||||
if (-not $id) { $id = Get-Date -Format "yyyyMMdd-HHmmssfff" }
|
if (-not $id) { $id = Get-Date -Format "yyyyMMdd-HHmmssfff" }
|
||||||
|
$ext = "$($Payload.audioExt)".Trim().TrimStart(".").ToLowerInvariant()
|
||||||
|
if (-not $ext) { $ext = "wav" }
|
||||||
|
if ($ext -notmatch '^[a-z0-9]{2,5}$') { $ext = "bin" }
|
||||||
|
|
||||||
$wavPath = Join-Path $inbox ("suno-upload-{0}.wav" -f $id)
|
$audioPath = Join-Path $inbox ("suno-upload-{0}.{1}" -f $id, $ext)
|
||||||
$bytes = [Convert]::FromBase64String("$($Payload.wavBase64)")
|
$bytes = [Convert]::FromBase64String($audioBase64)
|
||||||
[IO.File]::WriteAllBytes($wavPath, $bytes)
|
[IO.File]::WriteAllBytes($audioPath, $bytes)
|
||||||
$track | Add-Member -NotePropertyName "localWavPath" -NotePropertyValue $wavPath -Force
|
$track | Add-Member -NotePropertyName "localAudioPath" -NotePropertyValue $audioPath -Force
|
||||||
|
$track | Add-Member -NotePropertyName "localAudioExt" -NotePropertyValue $ext -Force
|
||||||
|
|
||||||
return Add-TracksToQueue @($track) "$($Payload.pageUrl)"
|
return Add-TracksToQueue @($track) "$($Payload.pageUrl)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user