mirror of
https://github.com/talwat/lowfi
synced 2025-02-04 23:01:27 +00:00
feat: add duration to tracks when possible
This commit is contained in:
parent
681889a268
commit
2102564d04
107
Cargo.lock
generated
107
Cargo.lock
generated
@ -110,6 +110,18 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@ -179,6 +191,12 @@ version = "3.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@ -909,6 +927,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.158"
|
||||
@ -951,6 +975,7 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
||||
name = "lowifi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"clap",
|
||||
"crossterm",
|
||||
@ -1010,26 +1035,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minimp3-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e21c73734c69dc95696c9ed8926a2b393171d98b3f5f5935686a26a487ab9b90"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimp3_fixed"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b0f14e7e75da97ae396c2656b10262a3d4afa2ec98f35795630eff0c8b951b"
|
||||
dependencies = [
|
||||
"minimp3-sys",
|
||||
"slice-ring-buffer",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.0"
|
||||
@ -1570,7 +1575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"minimp3_fixed",
|
||||
"symphonia",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
@ -1831,17 +1836,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slice-ring-buffer"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84ae312bda09b2368f79f985fdb4df4a0b5cbc75546b511303972d195f8c27d6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mach2",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.13.2"
|
||||
@ -1908,6 +1902,55 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.77"
|
||||
|
21
Cargo.toml
21
Cargo.toml
@ -4,13 +4,22 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Basics
|
||||
clap = { version = "4.5.18", features = ["derive", "cargo"] }
|
||||
reqwest = { version = "0.12.7", features = ["blocking"] }
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
scraper = "0.20.0"
|
||||
rodio = { version = "0.19.0", features = ["minimp3"], default-features = false }
|
||||
eyre = "0.6.12"
|
||||
futures = "0.3.30"
|
||||
bytes = "1.7.2"
|
||||
rand = "0.8.5"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
futures = "0.3.30"
|
||||
arc-swap = "1.7.1"
|
||||
|
||||
# Data
|
||||
reqwest = { version = "0.12.7", features = ["blocking"] }
|
||||
bytes = "1.7.2"
|
||||
|
||||
# Misc
|
||||
scraper = "0.20.0"
|
||||
rodio = { version = "0.19.0", features = ["mp3"], default-features = false }
|
||||
crossterm = "0.28.1"
|
||||
|
@ -1,5 +1,6 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod play;
|
||||
mod player;
|
||||
mod scrape;
|
||||
mod tracks;
|
||||
@ -26,6 +27,6 @@ async fn main() -> eyre::Result<()> {
|
||||
|
||||
match cli.command {
|
||||
Commands::Scrape => scrape::scrape().await,
|
||||
Commands::Play => player::play().await,
|
||||
Commands::Play => play::play().await,
|
||||
}
|
||||
}
|
||||
|
24
src/play.rs
Normal file
24
src/play.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::{
|
||||
sync::mpsc::{self},
|
||||
task::{self},
|
||||
};
|
||||
|
||||
use crate::player::Player;
|
||||
use crate::player::{ui, Messages};
|
||||
|
||||
pub async fn play() -> eyre::Result<()> {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
|
||||
let player = Arc::new(Player::new().await?);
|
||||
let audio = task::spawn(Player::play(player.clone(), rx));
|
||||
tx.send(Messages::Init).await?;
|
||||
|
||||
ui::start(player.clone(), tx.clone()).await?;
|
||||
|
||||
audio.abort();
|
||||
player.sink.stop();
|
||||
|
||||
Ok(())
|
||||
}
|
184
src/player.rs
184
src/player.rs
@ -1,11 +1,8 @@
|
||||
use std::{collections::VecDeque, io::stderr, sync::Arc};
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
|
||||
use crossterm::{
|
||||
cursor::{MoveDown, MoveToColumn, MoveToNextLine},
|
||||
style::Print,
|
||||
};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use reqwest::Client;
|
||||
use rodio::{Decoder, OutputStream, Sink};
|
||||
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
|
||||
use tokio::{
|
||||
select,
|
||||
sync::{
|
||||
@ -15,56 +12,69 @@ use tokio::{
|
||||
task,
|
||||
};
|
||||
|
||||
/// The amount of songs to buffer up.
|
||||
const BUFFER_SIZE: usize = 5;
|
||||
use crate::tracks::{Track, TrackInfo};
|
||||
|
||||
use crate::tracks::Track;
|
||||
pub mod ui;
|
||||
|
||||
/// Handles communication between the frontend & audio player.
|
||||
pub enum Messages {
|
||||
Skip,
|
||||
Die,
|
||||
Next,
|
||||
Init,
|
||||
Pause,
|
||||
}
|
||||
|
||||
/// Main struct responsible for queuing up tracks.
|
||||
///
|
||||
/// Internally tracks are stored in an [Arc],
|
||||
/// so it's fine to clone this struct.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Queue {
|
||||
tracks: Arc<RwLock<VecDeque<Track>>>,
|
||||
/// The amount of songs to buffer up.
|
||||
const BUFFER_SIZE: usize = 5;
|
||||
|
||||
/// Main struct responsible for queuing up & playing tracks.
|
||||
pub struct Player {
|
||||
pub sink: Sink,
|
||||
pub current: ArcSwapOption<TrackInfo>,
|
||||
tracks: RwLock<VecDeque<Track>>,
|
||||
client: Client,
|
||||
_handle: OutputStreamHandle,
|
||||
_stream: OutputStream,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
pub async fn new() -> Self {
|
||||
Self {
|
||||
tracks: Arc::new(RwLock::new(VecDeque::with_capacity(5))),
|
||||
unsafe impl Send for Player {}
|
||||
unsafe impl Sync for Player {}
|
||||
|
||||
impl Player {
|
||||
/// Initializes the entire player, including audio devices & sink.
|
||||
pub async fn new() -> eyre::Result<Self> {
|
||||
let (_stream, handle) = OutputStream::try_default()?;
|
||||
let sink = Sink::try_new(&handle)?;
|
||||
|
||||
Ok(Self {
|
||||
tracks: RwLock::new(VecDeque::with_capacity(5)),
|
||||
current: ArcSwapOption::new(None),
|
||||
client: Client::builder().build()?,
|
||||
sink,
|
||||
_handle: handle,
|
||||
_stream,
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_current(&self, info: TrackInfo) -> eyre::Result<()> {
|
||||
self.current.store(Some(Arc::new(info)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This will play the next track, as well as refilling the buffer in the background.
|
||||
pub async fn next(&self, client: &Client) -> eyre::Result<Track> {
|
||||
// This refills the queue in the background.
|
||||
task::spawn({
|
||||
let client = client.clone();
|
||||
let tracks = self.tracks.clone();
|
||||
pub async fn next(queue: Arc<Player>) -> eyre::Result<Track> {
|
||||
queue.current.store(None);
|
||||
|
||||
async move {
|
||||
while tracks.read().await.len() < BUFFER_SIZE {
|
||||
let track = Track::random(&client).await.unwrap();
|
||||
tracks.write().await.push_back(track);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let track = self.tracks.write().await.pop_front();
|
||||
let track = queue.tracks.write().await.pop_front();
|
||||
let track = match track {
|
||||
Some(x) => x,
|
||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
||||
// This is relevant particularly at the first song.
|
||||
None => Track::random(client).await?,
|
||||
None => Track::random(&queue.client).await?,
|
||||
};
|
||||
|
||||
queue.set_current(track.info).await?;
|
||||
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
@ -72,78 +82,54 @@ impl Queue {
|
||||
///
|
||||
/// `rx` is used to communicate with it, for example when to
|
||||
/// skip tracks or pause.
|
||||
pub async fn play(
|
||||
self,
|
||||
sink: Sink,
|
||||
client: Client,
|
||||
mut rx: Receiver<Messages>,
|
||||
) -> eyre::Result<()> {
|
||||
let sink = Arc::new(sink);
|
||||
pub async fn play(queue: Arc<Player>, mut rx: Receiver<Messages>) -> eyre::Result<()> {
|
||||
// This is an internal channel which serves pretty much only one purpose,
|
||||
// which is to notify the buffer refiller to get back to work.
|
||||
// This channel is useful to prevent needing to check with some infinite loop.
|
||||
let (itx, mut irx) = mpsc::channel(8);
|
||||
|
||||
// This refills the queue in the background.
|
||||
task::spawn({
|
||||
let queue = queue.clone();
|
||||
|
||||
async move {
|
||||
while let Some(()) = irx.recv().await {
|
||||
while queue.tracks.read().await.len() < BUFFER_SIZE {
|
||||
let track = Track::random(&queue.client).await.unwrap();
|
||||
queue.tracks.write().await.push_back(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
itx.send(()).await?;
|
||||
|
||||
loop {
|
||||
let clone = sink.clone();
|
||||
let clone = Arc::clone(&queue);
|
||||
let msg = select! {
|
||||
Some(x) = rx.recv() => x,
|
||||
|
||||
// This future will finish only at the end of the current track.
|
||||
Ok(()) = task::spawn_blocking(move || clone.sleep_until_end()) => Messages::Skip,
|
||||
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()) => Messages::Next,
|
||||
};
|
||||
|
||||
match msg {
|
||||
Messages::Skip => {
|
||||
sink.stop();
|
||||
Messages::Next | Messages::Init => {
|
||||
itx.send(()).await?;
|
||||
|
||||
let track = self.next(&client).await?;
|
||||
sink.append(Decoder::new(track.data)?);
|
||||
}
|
||||
Messages::Die => break,
|
||||
}
|
||||
}
|
||||
queue.sink.stop();
|
||||
|
||||
Ok(())
|
||||
let track = Player::next(queue.clone()).await?;
|
||||
queue.sink.append(track.data);
|
||||
}
|
||||
Messages::Pause => {
|
||||
if queue.sink.is_paused() {
|
||||
queue.sink.play();
|
||||
} else {
|
||||
queue.sink.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn gui() -> eyre::Result<()> {
|
||||
crossterm::execute!(stderr(), MoveToColumn(0), Print("hello!\r\n"))?;
|
||||
crossterm::execute!(stderr(), Print("next line!\r\n"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn play() -> eyre::Result<()> {
|
||||
let queue = Queue::new().await;
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let (_stream, handle) = OutputStream::try_default()?;
|
||||
let sink = Sink::try_new(&handle)?;
|
||||
let client = Client::builder().build()?;
|
||||
|
||||
let audio = task::spawn(queue.clone().play(sink, client.clone(), rx));
|
||||
tx.send(Messages::Skip).await?; // This is responsible for the initial track being played.
|
||||
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
|
||||
gui().await?;
|
||||
|
||||
'a: loop {
|
||||
match crossterm::event::read()? {
|
||||
crossterm::event::Event::Key(event) => match event.code {
|
||||
crossterm::event::KeyCode::Char(x) => {
|
||||
if x == 'q' {
|
||||
tx.send(Messages::Die).await?;
|
||||
|
||||
break 'a;
|
||||
} else if x == 's' {
|
||||
tx.send(Messages::Skip).await?;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
audio.abort();
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
Ok(())
|
||||
}
|
||||
|
84
src/player/ui.rs
Normal file
84
src/player/ui.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use std::{io::stderr, sync::Arc, time::Duration};
|
||||
|
||||
use super::Player;
|
||||
use crossterm::{
|
||||
cursor::{MoveTo, MoveToColumn, MoveUp},
|
||||
style::Print,
|
||||
terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use tokio::{
|
||||
sync::mpsc::Sender,
|
||||
task::{self},
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use super::Messages;
|
||||
|
||||
async fn interface(queue: Arc<Player>) -> eyre::Result<()> {
|
||||
const WIDTH: usize = 25;
|
||||
|
||||
loop {
|
||||
// We can get away with only redrawing every 0.25 seconds
|
||||
// since it's just an audio player.
|
||||
sleep(Duration::from_secs_f32(1.0 / 60.0)).await;
|
||||
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown))?;
|
||||
|
||||
let main = match queue.current.load().as_ref() {
|
||||
Some(x) => {
|
||||
if queue.sink.is_paused() {
|
||||
format!("paused {}\r\n", x.format_name())
|
||||
} else {
|
||||
format!("playing {}\r\n", x.format_name())
|
||||
}
|
||||
}
|
||||
None => "loading...\r\n".to_owned(),
|
||||
};
|
||||
|
||||
let bar = ["[s]kip", "[p]ause", "[q]uit"];
|
||||
|
||||
crossterm::execute!(stderr(), MoveToColumn(0), Print(main))?;
|
||||
crossterm::execute!(stderr(), Print(bar.join(" ")))?;
|
||||
crossterm::execute!(stderr(), MoveToColumn(0), MoveUp(1))?;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(queue: Arc<Player>, sender: Sender<Messages>) -> eyre::Result<()> {
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
crossterm::execute!(stderr(), EnterAlternateScreen, MoveTo(0, 0))?;
|
||||
|
||||
task::spawn(interface(queue.clone()));
|
||||
|
||||
loop {
|
||||
let crossterm::event::Event::Key(event) = crossterm::event::read()? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let crossterm::event::KeyCode::Char(code) = event.code else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match code {
|
||||
'q' => {
|
||||
break;
|
||||
}
|
||||
's' => {
|
||||
if !queue.current.load().is_none() {
|
||||
sender.send(Messages::Next).await?
|
||||
}
|
||||
}
|
||||
'p' => {
|
||||
sender.send(Messages::Pause).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
crossterm::execute!(
|
||||
stderr(),
|
||||
Clear(ClearType::FromCursorDown),
|
||||
LeaveAlternateScreen
|
||||
)?;
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,17 +1,19 @@
|
||||
use std::io::Cursor;
|
||||
use std::{io::Cursor, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use rodio::{Decoder, Source};
|
||||
|
||||
pub type Data = Cursor<Bytes>;
|
||||
pub type Data = Decoder<Cursor<Bytes>>;
|
||||
|
||||
async fn download(track: &str, client: &Client) -> eyre::Result<Data> {
|
||||
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
|
||||
let response = client.get(url).send().await?;
|
||||
let file = Cursor::new(response.bytes().await?);
|
||||
let source = Decoder::new(file)?;
|
||||
|
||||
Ok(file)
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
async fn random() -> eyre::Result<&'static str> {
|
||||
@ -24,9 +26,20 @@ async fn random() -> eyre::Result<&'static str> {
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Track {
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub struct TrackInfo {
|
||||
pub name: &'static str,
|
||||
pub duration: Option<Duration>,
|
||||
}
|
||||
|
||||
impl TrackInfo {
|
||||
pub fn format_name(&self) -> &'static str {
|
||||
self.name.split("/").nth(2).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Track {
|
||||
pub info: TrackInfo,
|
||||
pub data: Data,
|
||||
}
|
||||
|
||||
@ -35,6 +48,12 @@ impl Track {
|
||||
let name = random().await?;
|
||||
let data = download(&name, client).await?;
|
||||
|
||||
Ok(Self { name, data })
|
||||
Ok(Self {
|
||||
info: TrackInfo {
|
||||
name,
|
||||
duration: data.total_duration(),
|
||||
},
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user