Add info command and yt-dlp downloader for youtube
update some packages
This commit is contained in:
parent
9a7c53c67e
commit
16ee666c4f
3
@types/Downloader.d.ts
vendored
3
@types/Downloader.d.ts
vendored
@ -11,7 +11,8 @@ type ExistingResult = {
|
|||||||
} & BaseResult
|
} & BaseResult
|
||||||
|
|
||||||
type DownloadedResult = {
|
type DownloadedResult = {
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
temp?: boolean
|
||||||
} & BaseResult
|
} & BaseResult
|
||||||
|
|
||||||
export type DownloaderResult = ExistingResult | DownloadedResult;
|
export type DownloaderResult = ExistingResult | DownloadedResult;
|
3
@types/MusicPlayer.d.ts
vendored
3
@types/MusicPlayer.d.ts
vendored
@ -16,7 +16,8 @@ export type MusicIndexEntry = {
|
|||||||
album?: string,
|
album?: string,
|
||||||
year?: number,
|
year?: number,
|
||||||
genre: string[]
|
genre: string[]
|
||||||
file: string
|
file: string,
|
||||||
|
temp?: boolean // File is deleted after playing
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MusicStatsEntry = {
|
export type MusicStatsEntry = {
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
"@navy.gif/commandparser": "^1.8.2",
|
"@navy.gif/commandparser": "^1.8.2",
|
||||||
"@navy.gif/logger": "^2.5.4",
|
"@navy.gif/logger": "^2.5.4",
|
||||||
"@navy.gif/timestring": "^6.0.6",
|
"@navy.gif/timestring": "^6.0.6",
|
||||||
"@types/node": "^20.11.30",
|
"@types/node": "^20.14.11",
|
||||||
"bufferutil": "^4.0.8",
|
"bufferutil": "^4.0.8",
|
||||||
"common-tags": "^1.8.2",
|
"common-tags": "^1.8.2",
|
||||||
"discord.js": "^14.14.1",
|
"discord.js": "^14.14.1",
|
||||||
|
@ -6,6 +6,7 @@ import { LoggerClient } from '@navy.gif/logger';
|
|||||||
import Initialisable from '../../interfaces/Initialisable.js';
|
import Initialisable from '../../interfaces/Initialisable.js';
|
||||||
import Downloader from '../../interfaces/Downloader.js';
|
import Downloader from '../../interfaces/Downloader.js';
|
||||||
import DiscordClient from '../DiscordClient.js';
|
import DiscordClient from '../DiscordClient.js';
|
||||||
|
import DownloaderError from '../../errors/DownloaderError.js';
|
||||||
|
|
||||||
class MusicDownloader implements Initialisable
|
class MusicDownloader implements Initialisable
|
||||||
{
|
{
|
||||||
@ -52,7 +53,7 @@ class MusicDownloader implements Initialisable
|
|||||||
if (!downloader)
|
if (!downloader)
|
||||||
throw new Error('No such downloader');
|
throw new Error('No such downloader');
|
||||||
if (!downloader.available)
|
if (!downloader.available)
|
||||||
throw new Error('Downloader not available');
|
throw new DownloaderError('Downloader not available');
|
||||||
return downloader.download(url);
|
return downloader.download(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,13 @@ const defaultStats: MusicStatsEntry = {
|
|||||||
plays: 0,
|
plays: 0,
|
||||||
skips: 0
|
skips: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const domains = [
|
||||||
|
'youtube.com',
|
||||||
|
'youtu.be',
|
||||||
|
'soundcloud.com'
|
||||||
|
];
|
||||||
|
|
||||||
class MusicLibrary implements Initialisable
|
class MusicLibrary implements Initialisable
|
||||||
{
|
{
|
||||||
#path: string;
|
#path: string;
|
||||||
@ -85,9 +92,11 @@ class MusicLibrary implements Initialisable
|
|||||||
{
|
{
|
||||||
if (!linkReg.test(keyword))
|
if (!linkReg.test(keyword))
|
||||||
throw new MusicPlayerError('Invalid link');
|
throw new MusicPlayerError('Invalid link');
|
||||||
|
|
||||||
const match = keyword.match(linkReg);
|
const match = keyword.match(linkReg);
|
||||||
if (!match || !match.groups)
|
if (!match || !match.groups)
|
||||||
throw new Error('Missing match??');
|
throw new Error('Missing match??');
|
||||||
|
|
||||||
const { domain } = match.groups;
|
const { domain } = match.groups;
|
||||||
if (!domain)
|
if (!domain)
|
||||||
throw new MusicPlayerError('Invalid link');
|
throw new MusicPlayerError('Invalid link');
|
||||||
@ -96,6 +105,8 @@ class MusicLibrary implements Initialisable
|
|||||||
let result: DownloaderResult | null = null;
|
let result: DownloaderResult | null = null;
|
||||||
if (domain.includes('spotify.com'))
|
if (domain.includes('spotify.com'))
|
||||||
result = await this.#downloader.download('spotify', keyword);
|
result = await this.#downloader.download('spotify', keyword);
|
||||||
|
else if (domains.some(dom => domain.includes(dom)))
|
||||||
|
result = await this.#downloader.download('yt-dlp', keyword);
|
||||||
else
|
else
|
||||||
throw new MusicPlayerError('Unsupported domain');
|
throw new MusicPlayerError('Unsupported domain');
|
||||||
|
|
||||||
@ -109,12 +120,28 @@ class MusicLibrary implements Initialisable
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetDir = path.join(this.#path, result.artist, result.album);
|
let entry: MusicIndexEntry | null = null;
|
||||||
if (!fs.existsSync(targetDir))
|
if (result.temp)
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
{
|
||||||
const newFile = path.join(targetDir, `${result.song}.${result.ext}`);
|
entry = {
|
||||||
fs.renameSync(result.filePath, newFile);
|
id: -1,
|
||||||
const entry = await this.#indexSong(newFile, false);
|
artist: result.artist,
|
||||||
|
title: result.song,
|
||||||
|
album: result.album,
|
||||||
|
genre: [],
|
||||||
|
file: result.filePath,
|
||||||
|
temp: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const targetDir = path.join(this.#path, result.artist, result.album);
|
||||||
|
if (!fs.existsSync(targetDir))
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
const newFile = path.join(targetDir, `${result.song}.${result.ext}`);
|
||||||
|
fs.renameSync(result.filePath, newFile);
|
||||||
|
entry = await this.#indexSong(newFile, false);
|
||||||
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,7 +239,7 @@ class MusicLibrary implements Initialisable
|
|||||||
{
|
{
|
||||||
this.#logger.info('Starting library scan');
|
this.#logger.info('Starting library scan');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const filePaths = await Util.readdirRecursive(this.#path, { ignoreSuffixes: [ '.nfo', '.jpg', '.jpeg', '.png' ] });
|
const filePaths = await Util.readdirRecursive(this.#path, { ignoreSuffixes: [ '.nfo', '.jpg', '.jpeg', '.png', '.lrc' ] });
|
||||||
this.#logger.debug(`${filePaths.length} files to go through`);
|
this.#logger.debug(`${filePaths.length} files to go through`);
|
||||||
if (!this.#index.size)
|
if (!this.#index.size)
|
||||||
this.#logger.info('No index built, performing first time scan. This may take some time depending on the size of your music library');
|
this.#logger.info('No index built, performing first time scan. This may take some time depending on the size of your music library');
|
||||||
@ -241,11 +268,11 @@ class MusicLibrary implements Initialisable
|
|||||||
return newFiles;
|
return newFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #indexSong (fp: string, force: boolean)
|
async #indexSong (fp: string, force: boolean): Promise<MusicIndexEntry | null>
|
||||||
{
|
{
|
||||||
// Skip already scanned files
|
// Skip already scanned files
|
||||||
if (this.#index.has(fp) && !force)
|
if (this.#index.has(fp) && !force)
|
||||||
return;
|
return null;
|
||||||
|
|
||||||
// Expensive call
|
// Expensive call
|
||||||
let metadata = null;
|
let metadata = null;
|
||||||
@ -258,7 +285,7 @@ class MusicLibrary implements Initialisable
|
|||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
if (!error.message.includes('MIME-type'))
|
if (!error.message.includes('MIME-type'))
|
||||||
this.#logger.error(`Error while parsing file: ${fp}\n${error.stack}`);
|
this.#logger.error(`Error while parsing file: ${fp}\n${error.stack}`);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
const { common } = metadata;
|
const { common } = metadata;
|
||||||
// Fall back to file and folder name if artist or title not available
|
// Fall back to file and folder name if artist or title not available
|
||||||
@ -266,18 +293,14 @@ class MusicLibrary implements Initialisable
|
|||||||
const artist = segmets[segmets.length - 2];
|
const artist = segmets[segmets.length - 2];
|
||||||
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
|
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
|
||||||
|
|
||||||
const entry = {
|
const entry: MusicIndexEntry = {
|
||||||
id: this.#currentId++,
|
id: this.#currentId++,
|
||||||
artist: common.artist ?? artist,
|
artist: common.artist ?? artist,
|
||||||
title: common.title ?? title,
|
title: common.title ?? title,
|
||||||
album: common.album,
|
album: common.album,
|
||||||
year: common.year,
|
year: common.year,
|
||||||
genre: common.genre ?? [],
|
genre: common.genre ?? [],
|
||||||
file: fp,
|
file: fp
|
||||||
stats: {
|
|
||||||
plays: 0,
|
|
||||||
skips: 0
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
this.#index.set(fp, entry);
|
this.#index.set(fp, entry);
|
||||||
return entry;
|
return entry;
|
||||||
|
@ -140,6 +140,13 @@ class MusicPlayer implements Initialisable
|
|||||||
if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong)
|
if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong)
|
||||||
this.library.countSkip(this.#currentSong.title);
|
this.library.countSkip(this.#currentSong.title);
|
||||||
|
|
||||||
|
// Temporary song files are deleted after being played
|
||||||
|
if (this.#currentSong?.temp)
|
||||||
|
{
|
||||||
|
this.#logger.debug(`Deleting temp song file: ${this.#currentSong.file}`);
|
||||||
|
fs.unlinkSync(this.#currentSong.file);
|
||||||
|
}
|
||||||
|
|
||||||
let info: MusicIndexEntry | null = null;
|
let info: MusicIndexEntry | null = null;
|
||||||
if (this.#queue.length)
|
if (this.#queue.length)
|
||||||
info = this.#queue.shift()!;
|
info = this.#queue.shift()!;
|
||||||
@ -240,7 +247,6 @@ class MusicPlayer implements Initialisable
|
|||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
adapterCreator: guild.voiceAdapterCreator
|
adapterCreator: guild.voiceAdapterCreator
|
||||||
});
|
});
|
||||||
connection.removeAllListeners();
|
|
||||||
|
|
||||||
const subscription = connection.subscribe(this.#player);
|
const subscription = connection.subscribe(this.#player);
|
||||||
if (!subscription)
|
if (!subscription)
|
||||||
|
43
src/client/components/commands/Info.ts
Normal file
43
src/client/components/commands/Info.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { CommandArgs } from '@navy.gif/commandparser';
|
||||||
|
import Command from '../../../interfaces/Command.js';
|
||||||
|
import DiscordClient from '../../DiscordClient.js';
|
||||||
|
import { stripIndents } from 'common-tags';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
class InfoCommand extends Command
|
||||||
|
{
|
||||||
|
constructor (client: DiscordClient)
|
||||||
|
{
|
||||||
|
super(client, {
|
||||||
|
name: 'info',
|
||||||
|
description: 'Display information about a song',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'song',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute (_message: unknown, args: CommandArgs)
|
||||||
|
{
|
||||||
|
const [ songArg ] = args.map.get('song') ?? [];
|
||||||
|
const songName = songArg?.value as string | null;
|
||||||
|
let song = this.client.musicPlayer.current;
|
||||||
|
if (songName)
|
||||||
|
[ song ] = this.client.musicPlayer.library.search({ keyword: songName });
|
||||||
|
|
||||||
|
if (!song)
|
||||||
|
return `No song found matching ${songName}`;
|
||||||
|
return stripIndents`
|
||||||
|
**Name:** ${song.title}
|
||||||
|
**Artist:** ${song.artist}
|
||||||
|
**Genre:** ${song.genre.join(', ') || 'No genre info'}
|
||||||
|
**Year:** ${song.year ?? 'N/A'}
|
||||||
|
**Format:** ${path.extname(song.file).replace('.', '')}
|
||||||
|
**ID:** ${song.id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoCommand;
|
@ -4,6 +4,7 @@ import { CommandArgs } from '@navy.gif/commandparser';
|
|||||||
import Command from '../../../interfaces/Command.js';
|
import Command from '../../../interfaces/Command.js';
|
||||||
import DiscordClient from '../../DiscordClient.js';
|
import DiscordClient from '../../DiscordClient.js';
|
||||||
import MusicPlayerError from '../../../errors/MusicPlayerError.js';
|
import MusicPlayerError from '../../../errors/MusicPlayerError.js';
|
||||||
|
import DownloaderError from '../../../errors/DownloaderError.js';
|
||||||
|
|
||||||
class RequestCommand extends Command
|
class RequestCommand extends Command
|
||||||
{
|
{
|
||||||
@ -39,7 +40,7 @@ class RequestCommand extends Command
|
|||||||
}
|
}
|
||||||
catch (err)
|
catch (err)
|
||||||
{
|
{
|
||||||
if (err instanceof MusicPlayerError)
|
if (err instanceof MusicPlayerError || err instanceof DownloaderError)
|
||||||
return response.edit(`**Error while requesting song:**\n${err.message}`);
|
return response.edit(`**Error while requesting song:**\n${err.message}`);
|
||||||
this.logger.error(err as Error);
|
this.logger.error(err as Error);
|
||||||
return response.edit('Internal error, this has been logged');
|
return response.edit('Internal error, this has been logged');
|
||||||
|
142
src/client/components/downloaders/yt-dlp.ts
Normal file
142
src/client/components/downloaders/yt-dlp.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { DownloaderResult } from '../../../../@types/Downloader.js';
|
||||||
|
import Downloader from '../../../interfaces/Downloader.js';
|
||||||
|
import DiscordClient from '../../DiscordClient.js';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
type QueueEntry = {
|
||||||
|
resolve: (result: DownloaderResult) => void,
|
||||||
|
reject: (error: Error) => void,
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class YtdlpDownloader extends Downloader
|
||||||
|
{
|
||||||
|
#executable: string;
|
||||||
|
#available: boolean;
|
||||||
|
#options: string[];
|
||||||
|
#processing: boolean;
|
||||||
|
#queue: QueueEntry[];
|
||||||
|
|
||||||
|
constructor (client: DiscordClient)
|
||||||
|
{
|
||||||
|
super(client, {
|
||||||
|
name: 'yt-dlp'
|
||||||
|
});
|
||||||
|
this.#executable = 'yt-dlp';
|
||||||
|
this.#available = false;
|
||||||
|
this.#options = [
|
||||||
|
'--no-playlist', // Avoid downloading playlists
|
||||||
|
// '--limit-rate', '5M', // Download at 5MB/s
|
||||||
|
'--max-filesize', '10M', // Limit to 10 MB, should prevent downloading livestreams or overly large files
|
||||||
|
'--paths', 'cache/youtube', // Output dir
|
||||||
|
'--restrict-filenames', // Filename can only contain ASCII characters
|
||||||
|
'-x', // Extract audio
|
||||||
|
'--audio-format', 'mp3', // Extract audio in mp3
|
||||||
|
'--audio-quality', '0', // Extract as best audio quality
|
||||||
|
// '--remux-video', 'mp4', // Repackage the video in MP4 format
|
||||||
|
// '--recode-video', 'mp4', // Recode if necessary
|
||||||
|
// '-o {name}.%(ext)s', // Template for output file name
|
||||||
|
];
|
||||||
|
|
||||||
|
this.#queue = [];
|
||||||
|
this.#processing = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override get available (): boolean
|
||||||
|
{
|
||||||
|
return this.#available;
|
||||||
|
}
|
||||||
|
|
||||||
|
override test (): void | Promise<void>
|
||||||
|
{
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
exec(`${this.#executable} --help`, (error) =>
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
this.#available = false;
|
||||||
|
return reject(error);
|
||||||
|
}
|
||||||
|
this.#available = true;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override download (url: string): Promise<DownloaderResult>
|
||||||
|
{
|
||||||
|
if (!this.#available)
|
||||||
|
throw new Error('This downloader is not available, please install zotify on your system');
|
||||||
|
if (!this.downloadDir)
|
||||||
|
throw new Error('No download directory defined');
|
||||||
|
|
||||||
|
const promise = new Promise<DownloaderResult>((resolve, reject) =>
|
||||||
|
{
|
||||||
|
this.#queue.push({
|
||||||
|
resolve, reject, url
|
||||||
|
});
|
||||||
|
this.#processQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
promise.finally(() =>
|
||||||
|
{
|
||||||
|
this.#processing = false;
|
||||||
|
this.#processQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #processQueue ()
|
||||||
|
{
|
||||||
|
if (this.#processing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const entry = this.#queue.shift();
|
||||||
|
if (!entry)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.#processing = true;
|
||||||
|
|
||||||
|
const { resolve, reject, url } = entry;
|
||||||
|
this.logger.info(`Processing queue, downloading ${url}`);
|
||||||
|
const ts = Date.now();
|
||||||
|
exec(`${this.#executable} ${this.#options.join(' ')} -o ${ts}-%(title)s.%(ext)s ${url}`, (error, stdout, stderr) =>
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
return reject(error);
|
||||||
|
if (stderr && stderr.includes('ERROR: '))
|
||||||
|
{
|
||||||
|
this.logger.error(stderr);
|
||||||
|
return reject(new Error('Unknown error'));
|
||||||
|
}
|
||||||
|
this.logger.debug(stdout);
|
||||||
|
const files = fs.readdirSync('./cache/youtube');
|
||||||
|
const file = files.find(f => f.startsWith(ts.toString()));
|
||||||
|
if (!file)
|
||||||
|
return reject(new Error('Download failed, could not locate file'));
|
||||||
|
|
||||||
|
const ext = path.extname(file);
|
||||||
|
const name = file
|
||||||
|
.replace(`${ts}-`, '')
|
||||||
|
.replace('_', ' ')
|
||||||
|
.replace(ext, '');
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
filePath: path.resolve('./cache/youtube', file),
|
||||||
|
artist: 'Unknown',
|
||||||
|
album: 'Unknown',
|
||||||
|
song: name,
|
||||||
|
temp: true,
|
||||||
|
ext,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default YtdlpDownloader;
|
4
src/errors/DownloaderError.ts
Normal file
4
src/errors/DownloaderError.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
class DownloaderError extends Error
|
||||||
|
{}
|
||||||
|
|
||||||
|
export default DownloaderError;
|
13
yarn.lock
13
yarn.lock
@ -1844,7 +1844,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:*, @types/node@npm:^20.11.30":
|
"@types/node@npm:*":
|
||||||
version: 20.11.30
|
version: 20.11.30
|
||||||
resolution: "@types/node@npm:20.11.30"
|
resolution: "@types/node@npm:20.11.30"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1853,6 +1853,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/node@npm:^20.14.11":
|
||||||
|
version: 20.14.11
|
||||||
|
resolution: "@types/node@npm:20.14.11"
|
||||||
|
dependencies:
|
||||||
|
undici-types: "npm:~5.26.4"
|
||||||
|
checksum: 10/344e1ce1ed16c86ed1c4209ab4d1de67db83dd6b694a6fabe295c47144dde2c58dabddae9f39a0a2bdd246e95f8d141ccfe848e464884b48b8918df4f7788025
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/semver@npm:^7.5.0":
|
"@types/semver@npm:^7.5.0":
|
||||||
version: 7.5.8
|
version: 7.5.8
|
||||||
resolution: "@types/semver@npm:7.5.8"
|
resolution: "@types/semver@npm:7.5.8"
|
||||||
@ -3587,7 +3596,7 @@ __metadata:
|
|||||||
"@types/common-tags": "npm:^1"
|
"@types/common-tags": "npm:^1"
|
||||||
"@types/eslint": "npm:^8"
|
"@types/eslint": "npm:^8"
|
||||||
"@types/humanize-duration": "npm:^3"
|
"@types/humanize-duration": "npm:^3"
|
||||||
"@types/node": "npm:^20.11.30"
|
"@types/node": "npm:^20.14.11"
|
||||||
"@types/similarity": "npm:^1"
|
"@types/similarity": "npm:^1"
|
||||||
"@typescript-eslint/eslint-plugin": "npm:^7.3.1"
|
"@typescript-eslint/eslint-plugin": "npm:^7.3.1"
|
||||||
"@typescript-eslint/parser": "npm:^7.3.1"
|
"@typescript-eslint/parser": "npm:^7.3.1"
|
||||||
|
Loading…
Reference in New Issue
Block a user