mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-04 06:52:26 +02:00
- **Breaking CHANGE** Sort refactor, now with filter
- Fixed xtream api season info, filled missing seasons attribute - xtream api category_id = 0 filter ignored
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry};
|
||||
use crate::model::{AppConfig, ConfigInput};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster};
|
||||
use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster};
|
||||
use shared::utils::{generate_playlist_uuid, StringInterner};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use shared::concat_string;
|
||||
|
||||
pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc<AppConfig>, input: &ConfigInput) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
let config = &*app_config.config.load();
|
||||
@@ -230,6 +232,21 @@ pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
let release_date = series.year.map(|y| format!("{y}-01-01"));
|
||||
let youtube_trailer = series.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())).unwrap_or_default();
|
||||
|
||||
let mut season_data = HashMap::new();
|
||||
series.seasons.as_ref().iter().for_each(|seasons| seasons.iter().for_each(|season_metadata| {
|
||||
season_data.insert(season_metadata.season_number,SeriesStreamDetailSeasonProperties {
|
||||
name: season_metadata.name.clone(),
|
||||
season_number: season_metadata.season_number,
|
||||
episode_count: 0,
|
||||
overview: season_metadata.overview.clone(),
|
||||
air_date: season_metadata.air_date.clone(),
|
||||
cover: season_metadata.poster_path.clone(),
|
||||
cover_tmdb: season_metadata.poster_path.clone(),
|
||||
cover_big: None,
|
||||
duration: Some(String::from("0")),
|
||||
});
|
||||
}));
|
||||
|
||||
let episodes = series.episodes.as_ref().map(|episodes| {
|
||||
episodes.iter().filter(|episode| !episode.file_path.is_empty()).map(|episode| {
|
||||
let container_extension = Path::new(&episode.file_path)
|
||||
@@ -240,6 +257,20 @@ pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default();
|
||||
let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id);
|
||||
|
||||
let _ =season_data.entry(episode.season).or_insert_with(|| {
|
||||
SeriesStreamDetailSeasonProperties {
|
||||
name: concat_string!(&series.title, " ", &episode.season.to_string()),
|
||||
season_number: episode.season,
|
||||
episode_count: 0,
|
||||
overview: series.poster.clone(),
|
||||
air_date: episode.aired.clone(),
|
||||
cover: series.poster.clone(),
|
||||
cover_tmdb: None,
|
||||
cover_big: None,
|
||||
duration: None,
|
||||
}
|
||||
}).episode_count.saturating_add(1);
|
||||
|
||||
SeriesStreamDetailEpisodeProperties {
|
||||
id: tmdb_id.unwrap_or_default(),
|
||||
episode_num: episode.episode,
|
||||
@@ -252,6 +283,7 @@ pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
tmdb: tmdb_id,
|
||||
release_date: episode_release_date.clone(),
|
||||
plot: episode.plot.clone(),
|
||||
crew: series.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", ")),
|
||||
duration_secs: episode.runtime.map_or(0, |r| r * 60),
|
||||
duration: episode.runtime
|
||||
.map(|r| format!("{:02}:{:02}:00", r / 60, r % 60))
|
||||
@@ -265,6 +297,10 @@ pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
}).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
|
||||
let mut seasons = season_data.into_values().collect::<Vec<_>>();
|
||||
seasons.sort_by_key(|s| s.season_number);
|
||||
|
||||
let properties = SeriesStreamProperties {
|
||||
name: series.title.clone(),
|
||||
category_id: 0,
|
||||
@@ -293,7 +329,7 @@ pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
tmdb: series.tmdb_id,
|
||||
details: Some(SeriesStreamDetailProperties {
|
||||
year: series.year,
|
||||
// seasons are not delivered through xtream get_series_info.
|
||||
seasons: Some(seasons),
|
||||
episodes,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -116,8 +116,8 @@ fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec<ConfigRename>>, inter
|
||||
let result = pli;
|
||||
for r in renames {
|
||||
let value = get_field_value(result, r.field);
|
||||
let cap = r.pattern.replace_all(value.as_str(), &r.new_name);
|
||||
if log_enabled!(log::Level::Debug) && *value != cap {
|
||||
let cap = r.pattern.replace_all(&*value, &r.new_name);
|
||||
if log_enabled!(log::Level::Debug) && value != cap {
|
||||
trace_if_enabled!("Renamed {}={value} to {cap}", &r.field);
|
||||
}
|
||||
let value = cap.into_owned();
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
use crate::model::{ConfigSortChannel, ConfigSortGroup, ConfigTarget};
|
||||
use deunicode::deunicode;
|
||||
use crate::model::{ConfigSortRule, ConfigTarget};
|
||||
use std::cmp::Ordering;
|
||||
use shared::foundation::filter::get_field_value;
|
||||
use shared::model::{PlaylistGroup, PlaylistItem, SortOrder};
|
||||
use shared::foundation::filter::{ValueProvider};
|
||||
use shared::model::{PlaylistGroup, SortOrder, SortTarget};
|
||||
|
||||
fn direction(order: SortOrder, ordering: Ordering) -> Ordering {
|
||||
match order {
|
||||
SortOrder::Asc => if ordering == Ordering::Less { Ordering::Less } else { Ordering::Greater },
|
||||
SortOrder::Desc => if ordering == Ordering::Less { Ordering::Greater } else { Ordering::Less },
|
||||
SortOrder::None => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
|
||||
fn playlist_comparator(
|
||||
sequence: Option<&Vec<regex::Regex>>,
|
||||
@@ -77,16 +84,8 @@ fn playlist_comparator(
|
||||
SortOrder::None => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
(Some(_), None) => match order {
|
||||
SortOrder::Asc => Ordering::Less,
|
||||
SortOrder::Desc => Ordering::Greater,
|
||||
SortOrder::None => Ordering::Equal,
|
||||
},
|
||||
(None, Some(_)) => match order {
|
||||
SortOrder::Asc => Ordering::Greater,
|
||||
SortOrder::Desc => Ordering::Less,
|
||||
SortOrder::None => Ordering::Equal,
|
||||
},
|
||||
(Some(_), None) => direction(order, Ordering::Less),
|
||||
(None, Some(_)) => direction(order, Ordering::Greater),
|
||||
(None, None) => {
|
||||
// NP match → fallback
|
||||
let o = value_a.cmp(value_b);
|
||||
@@ -108,82 +107,165 @@ fn playlist_comparator(
|
||||
}
|
||||
}
|
||||
|
||||
fn playlistgroup_comparator(a: &PlaylistGroup, b: &PlaylistGroup, group_sort: &ConfigSortGroup, match_as_ascii: bool) -> Ordering {
|
||||
let ascii_a;
|
||||
let ascii_b;
|
||||
let value_a: &str = if match_as_ascii {
|
||||
ascii_a = deunicode(&a.title);
|
||||
&ascii_a
|
||||
} else {
|
||||
&a.title
|
||||
};
|
||||
let value_b: &str = if match_as_ascii {
|
||||
ascii_b = deunicode(&b.title);
|
||||
&ascii_b
|
||||
} else {
|
||||
&b.title
|
||||
pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) -> bool {
|
||||
let Some(sort) = &target.sort else {
|
||||
return false;
|
||||
};
|
||||
|
||||
playlist_comparator(group_sort.sequence.as_ref(), group_sort.order, value_a, value_b)
|
||||
let rules = &sort.rules;
|
||||
let match_as_ascii = sort.match_as_ascii;
|
||||
sort_groups(playlist, rules, match_as_ascii);
|
||||
sort_channels_in_groups(playlist, rules, match_as_ascii);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn playlistitem_comparator(
|
||||
a: &PlaylistItem,
|
||||
b: &PlaylistItem,
|
||||
channel_sort: &ConfigSortChannel,
|
||||
fn sort_groups(
|
||||
groups: &mut [PlaylistGroup],
|
||||
rules: &[ConfigSortRule],
|
||||
match_as_ascii: bool,
|
||||
) -> Ordering {
|
||||
let raw_value_a = get_field_value(a, channel_sort.field);
|
||||
let raw_value_b = get_field_value(b, channel_sort.field);
|
||||
let value_a = if match_as_ascii { deunicode(&raw_value_a) } else { raw_value_a };
|
||||
let value_b = if match_as_ascii { deunicode(&raw_value_b) } else { raw_value_b };
|
||||
) {
|
||||
let group_rules: Vec<_> = rules
|
||||
.iter()
|
||||
.filter(|r| matches!(r.target, SortTarget::Group))
|
||||
.collect();
|
||||
|
||||
playlist_comparator(channel_sort.sequence.as_ref(), channel_sort.order, &value_a, &value_b)
|
||||
}
|
||||
if group_rules.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, new_playlist: &mut [PlaylistGroup]) -> bool {
|
||||
if let Some(sort) = &target.sort {
|
||||
let match_as_ascii = sort.match_as_ascii;
|
||||
if let Some(group_sort) = &sort.groups {
|
||||
if !matches!(group_sort.order, SortOrder::None) {
|
||||
new_playlist.sort_by(|a, b| playlistgroup_comparator(a, b, group_sort, match_as_ascii));
|
||||
groups.sort_by(|a_grp, b_grp| {
|
||||
for rule in &group_rules {
|
||||
if rule.order == SortOrder::None {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(channel_sorts) = &sort.channels {
|
||||
for channel_sort in channel_sorts {
|
||||
if matches!(channel_sort.order, SortOrder::None) {
|
||||
continue;
|
||||
}
|
||||
let regexp = &channel_sort.group_pattern;
|
||||
for group in new_playlist.iter_mut() {
|
||||
let group_title = if match_as_ascii { deunicode(&group.title) } else { group.title.to_string() };
|
||||
if regexp.is_match(group_title.as_str()) {
|
||||
group.channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, channel_sort, match_as_ascii));
|
||||
|
||||
let (Some(a_chan), Some(b_chan)) = (a_grp.channels.first(), b_grp.channels.first()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let provider_a = ValueProvider { pli: a_chan, match_as_ascii };
|
||||
let provider_b = ValueProvider { pli: b_chan, match_as_ascii };
|
||||
|
||||
match (rule.filter.filter(&provider_a), rule.filter.filter(&provider_b)) {
|
||||
(false, false) => return Ordering::Equal,
|
||||
(true, false) => return direction(rule.order, Ordering::Less),
|
||||
(false, true) => return direction(rule.order, Ordering::Greater),
|
||||
(true, true) => { /* fallthrough */}
|
||||
}
|
||||
|
||||
let va = provider_a.get(rule.field.as_str());
|
||||
let vb = provider_b.get(rule.field.as_str());
|
||||
match (va, vb) {
|
||||
(None, None) => return Ordering::Equal,
|
||||
(Some(_), None) => return direction(rule.order, Ordering::Less),
|
||||
(None, Some(_)) => return direction(rule.order, Ordering::Greater),
|
||||
(Some(va), Some(vb)) => {
|
||||
let ord = playlist_comparator(
|
||||
rule.sequence.as_ref(),
|
||||
rule.order,
|
||||
&va,
|
||||
&vb,
|
||||
);
|
||||
|
||||
if ord != Ordering::Equal {
|
||||
return ord;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
||||
Ordering::Equal
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_channels_in_groups(
|
||||
groups: &mut [PlaylistGroup],
|
||||
rules: &[ConfigSortRule],
|
||||
match_as_ascii: bool,
|
||||
) {
|
||||
let channel_rules: Vec<_> = rules
|
||||
.iter()
|
||||
.filter(|r| matches!(r.target, SortTarget::Channel))
|
||||
.collect();
|
||||
|
||||
if channel_rules.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for group in groups {
|
||||
group.channels.sort_by(|a, b| {
|
||||
for rule in &channel_rules {
|
||||
if rule.order == SortOrder::None {
|
||||
continue;
|
||||
}
|
||||
|
||||
let provider_a = ValueProvider { pli: a, match_as_ascii };
|
||||
let provider_b = ValueProvider { pli: b, match_as_ascii };
|
||||
|
||||
match (rule.filter.filter(&provider_a), rule.filter.filter(&provider_b)) {
|
||||
(false, false) => continue,
|
||||
(true, false) => return direction(rule.order, Ordering::Less),
|
||||
(false, true) => return direction(rule.order, Ordering::Greater),
|
||||
(true, true) => {}
|
||||
}
|
||||
|
||||
let va = provider_a.get(rule.field.as_str());
|
||||
let vb = provider_b.get(rule.field.as_str());
|
||||
match (va, vb) {
|
||||
(None, None) => return Ordering::Equal,
|
||||
(Some(_), None) => return direction(rule.order, Ordering::Less),
|
||||
(None, Some(_)) => return direction(rule.order, Ordering::Greater),
|
||||
(Some(va), Some(vb)) => {
|
||||
let ord = playlist_comparator(
|
||||
rule.sequence.as_ref(),
|
||||
rule.order,
|
||||
&va,
|
||||
&vb,
|
||||
);
|
||||
|
||||
if ord != Ordering::Equal {
|
||||
return ord;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.header.source_ordinal.cmp(&b.header.source_ordinal)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::processing::processor::sort::playlistitem_comparator;
|
||||
use std::cmp::Ordering;
|
||||
use regex::Regex;
|
||||
use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder};
|
||||
use crate::model::ConfigSortChannel;
|
||||
use shared::foundation::filter::Filter;
|
||||
use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget};
|
||||
use crate::model::ConfigSortRule;
|
||||
use crate::processing::processor::sort::playlist_comparator;
|
||||
|
||||
#[test]
|
||||
fn test_sort() {
|
||||
let mut channels: Vec<PlaylistItem> = vec![
|
||||
("D", "HD"), ("A", "FHD"), ("Z", "HD"), ("K", "HD"), ("B", "HD"), ("A", "HD"),
|
||||
("K", "UHD"), ("C", "HD"), ("L", "FHD"), ("R", "UHD"), ("T", "SD"), ("A", "FHD"),
|
||||
].into_iter().map(|(name, quality)| PlaylistItem { header: PlaylistItemHeader { title: format!("Chanel {name} [{quality}]"), ..Default::default() } }).collect::<Vec<PlaylistItem>>();
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, quality))| PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
title: format!("Chanel {name} [{quality}]"),
|
||||
source_ordinal: i as u32,
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let channel_sort = ConfigSortChannel {
|
||||
let channel_sort = ConfigSortRule {
|
||||
target: SortTarget::Channel,
|
||||
field: ItemField::Caption,
|
||||
order: SortOrder::Asc,
|
||||
sequence: Some(vec![
|
||||
@@ -191,12 +273,47 @@ mod tests {
|
||||
Regex::new(r"(?P<c1>.*?)\bFHD\b").unwrap(),
|
||||
Regex::new(r"(?P<c1>.*?)\bHD\b").unwrap(),
|
||||
]),
|
||||
group_pattern: Regex::new(".*").unwrap(),
|
||||
filter: Filter::default(),
|
||||
};
|
||||
|
||||
channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, &channel_sort, true));
|
||||
let expected = vec!["Chanel K [UHD]", "Chanel R [UHD]", "Chanel A [FHD]", "Chanel A [FHD]", "Chanel L [FHD]", "Chanel A [HD]", "Chanel B [HD]", "Chanel C [HD]", "Chanel D [HD]", "Chanel K [HD]", "Chanel Z [HD]", "Chanel T [SD]"];
|
||||
let sorted = channels.into_iter().map(|pli| pli.header.title.clone()).collect::<Vec<String>>();
|
||||
channels.sort_by(|a, b| {
|
||||
let va = &a.header.title;
|
||||
let vb = &b.header.title;
|
||||
|
||||
let ord = playlist_comparator(
|
||||
channel_sort.sequence.as_ref(),
|
||||
channel_sort.order,
|
||||
va,
|
||||
vb,
|
||||
);
|
||||
|
||||
if ord != Ordering::Equal {
|
||||
ord
|
||||
} else {
|
||||
a.header.source_ordinal.cmp(&b.header.source_ordinal)
|
||||
}
|
||||
});
|
||||
|
||||
let expected = vec![
|
||||
"Chanel K [UHD]",
|
||||
"Chanel R [UHD]",
|
||||
"Chanel A [FHD]",
|
||||
"Chanel A [FHD]",
|
||||
"Chanel L [FHD]",
|
||||
"Chanel A [HD]",
|
||||
"Chanel B [HD]",
|
||||
"Chanel C [HD]",
|
||||
"Chanel D [HD]",
|
||||
"Chanel K [HD]",
|
||||
"Chanel Z [HD]",
|
||||
"Chanel T [SD]",
|
||||
];
|
||||
|
||||
let sorted = channels
|
||||
.into_iter()
|
||||
.map(|pli| pli.header.title)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(expected, sorted);
|
||||
}
|
||||
|
||||
@@ -227,9 +344,20 @@ mod tests {
|
||||
"US| East d",
|
||||
"US| West e",
|
||||
"US| West f",
|
||||
].into_iter().map(|name| PlaylistItem { header: PlaylistItemHeader { title: name.to_string(), ..Default::default() } }).collect::<Vec<PlaylistItem>>();
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
title: name.to_string(),
|
||||
source_ordinal: i as u32,
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let channel_sort = ConfigSortChannel {
|
||||
let channel_sort = ConfigSortRule {
|
||||
target: SortTarget::Channel,
|
||||
field: ItemField::Caption,
|
||||
order: SortOrder::Asc,
|
||||
sequence: Some(vec").unwrap(),
|
||||
Regex::new(r"^US\| WEST.*?\[\bSD\b\](?P<c1>.*)").unwrap(),
|
||||
]),
|
||||
group_pattern: Regex::new(".*").unwrap(),
|
||||
filter: Filter::default(),
|
||||
};
|
||||
|
||||
channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, &channel_sort, true));
|
||||
let sorted = channels.into_iter().map(|pli| pli.header.title.clone()).collect::<Vec<String>>();
|
||||
let expected = vec!["US| EAST [UHD] m", "US| EAST [FHD] abc", "US| EAST [FHD] bc", "US| EAST [FHD] de", "US| EAST [FHD] def", "US| EAST [FHD] ghi", "US| EAST [HD] f", "US| EAST [HD] h", "US| EAST [HD] jkl", "US| EAST [HD] mno", "US| EAST [HD] pqrs", "US| EAST [HD] tuv", "US| EAST [HD] wxy", "US| EAST [HD] z", "US| EAST [SD] a", "US| EAST [SD] ijk", "US| EAST [SD] l", "US| WEST [FHD] no", "US| WEST [HD] qrst", "US| WEST [HD] uvw", "US| (West) xv", "US| East d", "US| West e", "US| West f"];
|
||||
channels.sort_by(|a, b| {
|
||||
let ord = playlist_comparator(
|
||||
channel_sort.sequence.as_ref(),
|
||||
channel_sort.order,
|
||||
&a.header.title,
|
||||
&b.header.title,
|
||||
);
|
||||
|
||||
if ord != Ordering::Equal {
|
||||
ord
|
||||
} else {
|
||||
a.header.source_ordinal.cmp(&b.header.source_ordinal)
|
||||
}
|
||||
});
|
||||
|
||||
let expected = vec![
|
||||
"US| EAST [UHD] m",
|
||||
"US| EAST [FHD] abc",
|
||||
"US| EAST [FHD] bc",
|
||||
"US| EAST [FHD] de",
|
||||
"US| EAST [FHD] def",
|
||||
"US| EAST [FHD] ghi",
|
||||
"US| EAST [HD] f",
|
||||
"US| EAST [HD] h",
|
||||
"US| EAST [HD] jkl",
|
||||
"US| EAST [HD] mno",
|
||||
"US| EAST [HD] pqrs",
|
||||
"US| EAST [HD] tuv",
|
||||
"US| EAST [HD] wxy",
|
||||
"US| EAST [HD] z",
|
||||
"US| EAST [SD] a",
|
||||
"US| EAST [SD] ijk",
|
||||
"US| EAST [SD] l",
|
||||
"US| WEST [FHD] no",
|
||||
"US| WEST [HD] qrst",
|
||||
"US| WEST [HD] uvw",
|
||||
"US| (West) xv",
|
||||
"US| East d",
|
||||
"US| West e",
|
||||
"US| West f",
|
||||
];
|
||||
|
||||
let sorted = channels
|
||||
.into_iter()
|
||||
.map(|pli| pli.header.title)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(expected, sorted);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user