feat: add AniList browsing with on-demand ZokoAnime playback
This commit is contained in:
+204
-39
@@ -21,7 +21,7 @@ use ratatui::{
|
||||
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tubular::miruro::Identity;
|
||||
use tubular::anime::{Identity, Provider};
|
||||
|
||||
use crate::{
|
||||
Options, PreviewMode, config,
|
||||
@@ -92,6 +92,7 @@ struct DisplayRow {
|
||||
}
|
||||
struct Screen {
|
||||
content: Content,
|
||||
provider: Provider,
|
||||
selection: ListState,
|
||||
checked: Vec<String>,
|
||||
description_scroll: u16,
|
||||
@@ -105,6 +106,7 @@ impl Screen {
|
||||
fn new(content: Content) -> Self {
|
||||
let mut screen = Self {
|
||||
content,
|
||||
provider: Provider::Miruro,
|
||||
selection: ListState::default(),
|
||||
checked: Vec::new(),
|
||||
description_scroll: 0,
|
||||
@@ -223,7 +225,7 @@ impl Screen {
|
||||
self.overview.clear();
|
||||
match &self.content {
|
||||
Content::Catalog { media, .. } => {
|
||||
self.title = "Miruro · Catalog (autocomplete, up to 8 titles)".into();
|
||||
self.title = format!("{} · Catalog (up to 8 titles)", self.provider.name());
|
||||
self.rows = media
|
||||
.iter()
|
||||
.map(|media| DisplayRow {
|
||||
@@ -235,13 +237,30 @@ impl Screen {
|
||||
.collect();
|
||||
}
|
||||
Content::Series { media, data } => {
|
||||
self.title = format!("Miruro · {} · Episodes", safe(&media.title));
|
||||
self.title = format!(
|
||||
"{} · {} · Episodes",
|
||||
self.provider.name(),
|
||||
safe(&media.title)
|
||||
);
|
||||
self.overview = media_details(media);
|
||||
if let Some(data) = data {
|
||||
self.overview.push_str(&format!(
|
||||
"\n\n{} returned episodes (source availability is checked on play).",
|
||||
data.len()
|
||||
));
|
||||
if self.provider == Provider::AniList {
|
||||
self.overview.push_str("\n\nProvider availability UNVERIFIED. Sub only; resolved on selection, not a stream inventory.");
|
||||
if media
|
||||
.metadata
|
||||
.get("catalog_count")
|
||||
.is_none_or(Value::is_null)
|
||||
{
|
||||
self.overview.push_str("\nCatalog/airing count unknown. Use search/edit (i by default) here to select an explicit positive episode number.");
|
||||
} else {
|
||||
self.overview.push_str(&format!("\n{} catalog/airing episode numbers (planned totals are not availability).", data.len()));
|
||||
}
|
||||
} else {
|
||||
self.overview.push_str(&format!(
|
||||
"\n\n{} returned episodes (source availability is checked on play).",
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
self.rows = data
|
||||
.iter()
|
||||
.map(|episode| {
|
||||
@@ -261,6 +280,9 @@ impl Screen {
|
||||
if let Some(seconds) = episode.duration_seconds {
|
||||
details.push(format!("{}:{:02}", seconds / 60, seconds % 60));
|
||||
}
|
||||
if self.provider == Provider::AniList {
|
||||
details.push("UNVERIFIED · sub".into());
|
||||
}
|
||||
let details = details.join(" · ");
|
||||
let mut metadata = title.clone();
|
||||
if !details.is_empty() {
|
||||
@@ -297,7 +319,8 @@ impl Screen {
|
||||
videos,
|
||||
} => {
|
||||
self.title = format!(
|
||||
"Miruro · {} episodes{}",
|
||||
"{} · {} episodes{}",
|
||||
self.provider.name(),
|
||||
collection_name(*kind),
|
||||
if matches!(kind, CollectionKind::WatchLater) {
|
||||
format!(" · {}", filter.label())
|
||||
@@ -308,13 +331,14 @@ impl Screen {
|
||||
self.rows = videos.iter().map(|video| {
|
||||
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
||||
let title = safe(&video.title);
|
||||
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter sends the episode URL to Terebi, which resolves sources and playback metadata.", identity.episode, identity.media_id);
|
||||
let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Provider availability UNVERIFIED. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id);
|
||||
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
||||
}).collect();
|
||||
}
|
||||
Content::Library { categories, shows } => {
|
||||
self.title = format!(
|
||||
"Miruro · Saved shows · {}",
|
||||
"{} · Saved shows · {}",
|
||||
self.provider.name(),
|
||||
if categories.is_empty() {
|
||||
"All saved shows".into()
|
||||
} else {
|
||||
@@ -347,6 +371,8 @@ impl Screen {
|
||||
// History owns the actual screens, so back/forward never has to refetch a catalog.
|
||||
pub(crate) struct Browser {
|
||||
options: Options,
|
||||
provider: Provider,
|
||||
episode_input: bool,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
pub(crate) state: State,
|
||||
@@ -435,11 +461,19 @@ impl Browser {
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
mut state: State,
|
||||
) -> Self {
|
||||
let provider = if options.anilist {
|
||||
Provider::AniList
|
||||
} else {
|
||||
Provider::Miruro
|
||||
};
|
||||
state.select_catalog(provider);
|
||||
let mut query = String::new();
|
||||
crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
|
||||
let mut browser = Self {
|
||||
provider,
|
||||
episode_input: false,
|
||||
options,
|
||||
config_path,
|
||||
config,
|
||||
@@ -463,6 +497,8 @@ impl Browser {
|
||||
intent: None,
|
||||
player: None,
|
||||
};
|
||||
browser.screens[0].provider = provider;
|
||||
browser.screens[0].rebuild_display();
|
||||
if matches!(&browser.screen().content, Content::Catalog { query, .. } if !query.trim().is_empty())
|
||||
{
|
||||
browser.search();
|
||||
@@ -486,7 +522,9 @@ impl Browser {
|
||||
self.intent = Some(intent);
|
||||
self.screen_mut().error = None;
|
||||
}
|
||||
fn push(&mut self, screen: Screen) {
|
||||
fn push(&mut self, mut screen: Screen) {
|
||||
screen.provider = self.provider;
|
||||
screen.rebuild_display();
|
||||
self.invalidate();
|
||||
self.screens.truncate(self.index + 1);
|
||||
self.screens.push(screen);
|
||||
@@ -516,6 +554,18 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn edit_search(&mut self) {
|
||||
self.episode_input = false;
|
||||
if self.provider == Provider::AniList
|
||||
&& matches!(&self.screen().content, Content::Series { media, data: Some(_) } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
|
||||
{
|
||||
self.invalidate();
|
||||
self.preview = None;
|
||||
self.input = Some(String::new());
|
||||
self.episode_input = true;
|
||||
self.status =
|
||||
"Enter an explicit episode number; availability UNVERIFIED (sub only).".into();
|
||||
return;
|
||||
}
|
||||
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
||||
self.push(Screen::new(Content::Catalog {
|
||||
query: String::new(),
|
||||
@@ -529,6 +579,36 @@ impl Browser {
|
||||
}
|
||||
self.preview = None;
|
||||
}
|
||||
fn select_episode(&mut self, input: &str) {
|
||||
let result = (|| -> io::Result<()> {
|
||||
let Content::Series {
|
||||
media,
|
||||
data: Some(data),
|
||||
} = &mut self.screen_mut().content
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let identity = Identity::from_key(&format!("anilist:{}:{}", media.id, input.trim()))?;
|
||||
let episode = miruro_browser::anilist_episode(media, identity.episode)?;
|
||||
let index = data
|
||||
.iter()
|
||||
.position(|item| item.identity == identity)
|
||||
.unwrap_or(data.len());
|
||||
if index == data.len() {
|
||||
data.push(episode);
|
||||
}
|
||||
self.screen_mut().selection.select(Some(index));
|
||||
self.screen_mut().rebuild_display();
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.screen_mut().error = None;
|
||||
self.status = "Explicit episode selected; provider availability UNVERIFIED. Enter resolves sub on demand.".into();
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
}
|
||||
fn search(&mut self) {
|
||||
let Content::Catalog { query, .. } = &self.screen().content else {
|
||||
return;
|
||||
@@ -550,14 +630,18 @@ impl Browser {
|
||||
return;
|
||||
}
|
||||
self.request(Request::Search(query), Intent::Catalog);
|
||||
self.status = "Searching Miruro catalog…".into();
|
||||
self.status = format!("Searching {} catalog…", self.provider.name());
|
||||
}
|
||||
fn load_series(&mut self) {
|
||||
let Content::Series { media, .. } = &self.screen().content else {
|
||||
return;
|
||||
};
|
||||
let id = media.id.clone();
|
||||
let slug = miruro_browser::slug(&media.title);
|
||||
let slug = if self.provider == Provider::AniList {
|
||||
Ok(String::new())
|
||||
} else {
|
||||
miruro_browser::slug(&media.title)
|
||||
};
|
||||
self.screen_mut().checked.clear();
|
||||
match slug {
|
||||
Ok(slug) => {
|
||||
@@ -568,7 +652,7 @@ impl Browser {
|
||||
},
|
||||
Intent::Series(id),
|
||||
);
|
||||
self.status = "Loading returned episodes…".into();
|
||||
self.status = "Loading catalog episode metadata…".into();
|
||||
}
|
||||
Err(error) => {
|
||||
self.invalidate();
|
||||
@@ -676,7 +760,7 @@ impl Browser {
|
||||
}
|
||||
(_, Err(error)) => self.fail(error),
|
||||
_ => self.fail(io::Error::other(
|
||||
"Miruro response did not match the current request",
|
||||
"Anime catalog response did not match the current request",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -686,7 +770,8 @@ impl Browser {
|
||||
}
|
||||
if self.pending.is_some() && !self.worker.as_ref().is_some_and(|worker| worker.busy) {
|
||||
let request = self.pending.take().expect("pending request");
|
||||
let worker = self.worker.get_or_insert_with(Worker::new);
|
||||
let provider = self.provider;
|
||||
let worker = self.worker.get_or_insert_with(|| Worker::new(provider));
|
||||
if let Err(error) = worker.submit(self.generation, request) {
|
||||
self.intent = None;
|
||||
self.fail(error);
|
||||
@@ -709,7 +794,7 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn collection(&mut self, kind: CollectionKind) {
|
||||
let videos = collection_videos(&self.state, kind, WatchFilter::All);
|
||||
let videos = collection_videos(&self.state, kind, WatchFilter::All, self.provider);
|
||||
let screen = Screen::new(Content::Collection {
|
||||
kind,
|
||||
filter: WatchFilter::All,
|
||||
@@ -952,7 +1037,7 @@ impl Browser {
|
||||
.selected()
|
||||
.and_then(|index| videos.get(index))
|
||||
.map(|video| video.id.clone());
|
||||
let replacement = collection_videos(&self.state, *kind, *filter);
|
||||
let replacement = collection_videos(&self.state, *kind, *filter, self.provider);
|
||||
let next = selected
|
||||
.and_then(|id| replacement.iter().position(|video| video.id == id))
|
||||
.or(screen.selection.selected());
|
||||
@@ -1032,7 +1117,7 @@ impl Browser {
|
||||
// Even the confirmation snapshot is provider-scoped at the mutation seam.
|
||||
let ids: Vec<_> = ids
|
||||
.into_iter()
|
||||
.filter(|id| Identity::from_key(id).is_ok())
|
||||
.filter(|id| self.provider.includes(id))
|
||||
.collect();
|
||||
match self.state.remove(kind, &ids) {
|
||||
Ok(()) => {
|
||||
@@ -1197,12 +1282,20 @@ impl Browser {
|
||||
match action {
|
||||
Some(Action::InputSubmit) => {
|
||||
let input = self.input.take().expect("input active");
|
||||
if let Content::Catalog { query, .. } = &mut self.screen_mut().content {
|
||||
*query = input;
|
||||
if self.episode_input {
|
||||
self.episode_input = false;
|
||||
self.select_episode(&input);
|
||||
} else {
|
||||
if let Content::Catalog { query, .. } = &mut self.screen_mut().content {
|
||||
*query = input;
|
||||
}
|
||||
self.search();
|
||||
}
|
||||
self.search();
|
||||
}
|
||||
Some(Action::InputCancel) => self.input = None,
|
||||
Some(Action::InputCancel) => {
|
||||
self.input = None;
|
||||
self.episode_input = false;
|
||||
}
|
||||
_ => {
|
||||
// Typed controls are rejected too, not merely bracketed paste.
|
||||
if !matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
|
||||
@@ -1216,7 +1309,17 @@ impl Browser {
|
||||
Some(Action::ChannelTags) => self.tags(),
|
||||
Some(Action::CategoryBrowse) => self.category_picker(),
|
||||
Some(Action::Help) => self.help = Some(0),
|
||||
Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(),
|
||||
Some(Action::SearchEdit) => self.edit_search(),
|
||||
Some(Action::ViewSearch) => {
|
||||
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
||||
self.push(Screen::new(Content::Catalog {
|
||||
query: String::new(),
|
||||
media: Vec::new(),
|
||||
searched: false,
|
||||
}));
|
||||
}
|
||||
self.edit_search();
|
||||
}
|
||||
Some(Action::ViewPrevious) => self.history(false),
|
||||
Some(Action::ViewNext) => self.history(true),
|
||||
Some(Action::ViewRefresh) => self.refresh(),
|
||||
@@ -1323,7 +1426,14 @@ impl Browser {
|
||||
}
|
||||
if self.input.is_some() {
|
||||
return vec![
|
||||
(Action::InputSubmit, "Search catalog"),
|
||||
(
|
||||
Action::InputSubmit,
|
||||
if self.episode_input {
|
||||
"Select explicit episode (UNVERIFIED)"
|
||||
} else {
|
||||
"Search catalog"
|
||||
},
|
||||
),
|
||||
(Action::InputCancel, "Cancel input"),
|
||||
(Action::DeleteCharacter, "Delete character"),
|
||||
(Action::DeleteWord, "Delete word"),
|
||||
@@ -1442,7 +1552,16 @@ impl Browser {
|
||||
{
|
||||
let mut actions = vec![
|
||||
(Action::Help, "help"),
|
||||
(Action::SearchEdit, "search"),
|
||||
(
|
||||
Action::SearchEdit,
|
||||
if self.provider == Provider::AniList
|
||||
&& matches!(&self.screen().content, Content::Series { media, .. } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
|
||||
{
|
||||
"select episode"
|
||||
} else {
|
||||
"search"
|
||||
},
|
||||
),
|
||||
(Action::InfoOverview, "info"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
@@ -1717,7 +1836,8 @@ impl Browser {
|
||||
if area.width < 24 || area.height < 10 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Miruro\nTerminal too small; enlarge to browse.\n{} exits",
|
||||
"{}\nTerminal too small; enlarge to browse.\n{} exits",
|
||||
self.provider.name(),
|
||||
self.config.keys.label(Action::Quit)
|
||||
))
|
||||
.wrap(Wrap { trim: true }),
|
||||
@@ -1749,7 +1869,13 @@ impl Browser {
|
||||
ui::search(
|
||||
frame,
|
||||
query_area,
|
||||
" tubular / Miruro search ",
|
||||
if self.episode_input {
|
||||
" Explicit episode · UNVERIFIED · sub "
|
||||
} else if self.provider == Provider::AniList {
|
||||
" tubular / AniList search "
|
||||
} else {
|
||||
" tubular / Miruro legacy search "
|
||||
},
|
||||
query,
|
||||
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
|
||||
);
|
||||
@@ -1771,13 +1897,15 @@ impl Browser {
|
||||
self.config.keys.label(Action::SearchEdit)
|
||||
)
|
||||
} else if self.intent.is_some() {
|
||||
"Loading Miruro metadata…".into()
|
||||
format!("Loading {} metadata…", self.provider.name())
|
||||
} else {
|
||||
match &self.screen().content {
|
||||
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
||||
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for episode metadata; AniList provider availability is UNVERIFIED.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
||||
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
|
||||
Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Provider availability UNVERIFIED; sub resolved on selection.", self.config.keys.label(Action::SearchEdit)),
|
||||
Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".into(),
|
||||
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
|
||||
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
|
||||
Content::Collection { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()),
|
||||
Content::Library { .. } => "No saved shows in these categories. Save a catalog title with the show tags action, then confirm.".into(),
|
||||
}
|
||||
};
|
||||
@@ -1895,7 +2023,7 @@ impl Browser {
|
||||
if let Some((kind, ids)) = &self.removal {
|
||||
frame.render_widget(Clear, popup);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind)))
|
||||
Paragraph::new(format!("Remove {} {} episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other providers, collections and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), self.provider.name(), collection_name(*kind)))
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(
|
||||
Block::bordered()
|
||||
@@ -1906,7 +2034,11 @@ impl Browser {
|
||||
);
|
||||
} else if let Some(scroll) = self.help {
|
||||
let mut lines = vec![
|
||||
Line::from("Miruro catalog → series → actual episodes").bold(),
|
||||
Line::from(format!(
|
||||
"{} catalog → series → episodes · availability UNVERIFIED",
|
||||
self.provider.name()
|
||||
))
|
||||
.bold(),
|
||||
Line::from(
|
||||
"Terebi resolves episode URLs, sources, subtitles and playback metadata.",
|
||||
),
|
||||
@@ -1918,7 +2050,7 @@ impl Browser {
|
||||
}));
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::bordered()
|
||||
.title(" Miruro keyboard help ")
|
||||
.title(format!(" {} keyboard help ", self.provider.name()))
|
||||
.border_style(ui::ACCENT);
|
||||
let inner = block.inner(popup).inner(Margin::new(1, 0));
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
|
||||
@@ -2035,12 +2167,17 @@ fn library_shows(
|
||||
shows
|
||||
}
|
||||
|
||||
fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> {
|
||||
fn collection_videos(
|
||||
state: &State,
|
||||
kind: CollectionKind,
|
||||
filter: WatchFilter,
|
||||
provider: Provider,
|
||||
) -> Vec<Video> {
|
||||
state
|
||||
.videos(kind)
|
||||
.iter()
|
||||
.filter(|video| {
|
||||
Identity::from_key(&video.id).is_ok()
|
||||
provider.includes(&video.id)
|
||||
&& (!matches!(kind, CollectionKind::WatchLater)
|
||||
|| filter.includes(state.is_watched(&video.id)))
|
||||
})
|
||||
@@ -2106,6 +2243,22 @@ fn media_summary(media: &Media) -> String {
|
||||
if let Some(count) = media.metadata.get("episode_count").and_then(Value::as_u64) {
|
||||
parts.push(format!("{count} planned"));
|
||||
}
|
||||
if miruro_browser::media_provider(media) == Provider::AniList {
|
||||
parts.push(
|
||||
match media.metadata.get("catalog_count").and_then(Value::as_u64) {
|
||||
Some(count) => format!("{count} catalog/airing numbers"),
|
||||
None => "catalog/airing count unknown".into(),
|
||||
},
|
||||
);
|
||||
if media
|
||||
.metadata
|
||||
.get("episode_count")
|
||||
.is_none_or(Value::is_null)
|
||||
{
|
||||
parts.push("total unknown".into());
|
||||
}
|
||||
parts.push("provider availability UNVERIFIED · sub".into());
|
||||
}
|
||||
for (field, label) in [
|
||||
("raw", "raw listed"),
|
||||
("sub", "sub listed"),
|
||||
@@ -2176,7 +2329,11 @@ fn media_details(media: &Media) -> String {
|
||||
}
|
||||
|
||||
impl Browser {
|
||||
pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<Option<bool>> {
|
||||
pub(crate) fn run(
|
||||
&mut self,
|
||||
terminal: &mut DefaultTerminal,
|
||||
) -> io::Result<Option<crate::BrowseProvider>> {
|
||||
self.state.select_catalog(self.provider);
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
@@ -2204,7 +2361,15 @@ impl Browser {
|
||||
if self.config.keys.resolve(self.context(), key)
|
||||
== Some(Action::ProviderChoose)
|
||||
{
|
||||
return crate::choose_provider(terminal, &self.config.keys, Some(true));
|
||||
return crate::choose_provider(
|
||||
terminal,
|
||||
&self.config.keys,
|
||||
Some(if self.provider == Provider::AniList {
|
||||
crate::BrowseProvider::AniList
|
||||
} else {
|
||||
crate::BrowseProvider::Miruro
|
||||
}),
|
||||
);
|
||||
}
|
||||
if self.key(key) {
|
||||
return Ok(None);
|
||||
|
||||
Reference in New Issue
Block a user