mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
feat(lyrics): add SpotiFLAC fallback for Apple Music outages
This commit is contained in:
1 parent
62ce404390
commit
e049604b24
6 files changed
+372
-23
No files matched your search
@@ -143,6 +143,14 @@ class AboutPage extends StatelessWidget {
|
||||
onTap: () => _launchUrl('https://squigly.link'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: 'SpotiFLAC-Mobile Lyrics',
|
||||
subtitle: context.l10n.lyricsProviderAppleMusicDesc,
|
||||
onTap: () =>
|
||||
_launchUrl('https://api.zarz.moe/apple-music/docs'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: 'Paxsenix',
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn available_providers() -> Vec<AvailableProvider> {
|
||||
"apple_music",
|
||||
"Apple Music",
|
||||
true,
|
||||
"Apple Music synced lyrics",
|
||||
"Apple Music synced lyrics with a SpotiFLAC-Mobile fallback",
|
||||
),
|
||||
(
|
||||
"qqmusic",
|
||||
|
||||
@@ -157,6 +157,7 @@ pub fn source_uses_proxy(source: &str) -> bool {
|
||||
!source.is_empty()
|
||||
&& ![
|
||||
"lrclib",
|
||||
"spotiflac-mobile lyrics",
|
||||
"kugou direct",
|
||||
"qq music direct",
|
||||
"genius direct",
|
||||
@@ -249,6 +250,21 @@ pub fn with_metadata(lyrics: &LyricsResponse, track: &str, artist: &str) -> Stri
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_party_lyrics_do_not_credit_paxsenix() {
|
||||
for source in [
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
"SpotiFLAC-Mobile Lyrics (cached)",
|
||||
] {
|
||||
let lyrics = LyricsResponse::from_text("[00:01.00]Example", source, source);
|
||||
let saved = with_metadata(&lyrics, "Track", "Artist");
|
||||
assert!(!saved.contains("Paxsenix"));
|
||||
assert_eq!(extract_source(&saved), source);
|
||||
}
|
||||
let lyrics = LyricsResponse::from_text("[00:01.00]Example", "Spotify", "Spotify");
|
||||
assert!(with_metadata(&lyrics, "Track", "Artist").contains("via Paxsenix API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_placeholders_are_plain_lyrics_without_losing_source_or_text() {
|
||||
let raw = "[ti:Example]\n[by:Example app (source: extension:example)]\n\n[00:00.00]First line\n[00:00.00]Second line\n[01:00.00]";
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct PaxLine {
|
||||
pub opposite_turn: bool,
|
||||
pub background: bool,
|
||||
pub background_text: Option<Vec<PaxDetail>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background_lines: Option<Vec<PaxLine>>,
|
||||
pub endtime: isize,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub agent: String,
|
||||
@@ -31,6 +33,7 @@ pub struct PaxLine {
|
||||
json::go_deserialize!(PaxLine {
|
||||
"text" => text, "timestamp" => timestamp, "oppositeturn" => opposite_turn,
|
||||
"background" => background, "backgroundtext" => background_text, "endtime" => endtime,
|
||||
"backgroundlines" => background_lines,
|
||||
"agent" => agent,
|
||||
});
|
||||
|
||||
@@ -227,6 +230,72 @@ pub fn format_pax_content(
|
||||
output.trim().into()
|
||||
}
|
||||
|
||||
/// The first-party service's exported eLRC flattens backing vocals into lead
|
||||
/// lines. Read its v1 JSON instead to preserve background groups and exact
|
||||
/// word boundaries, including adjacent syllables and line-only lyrics.
|
||||
pub fn format_spotiflac_apple(
|
||||
raw: &str,
|
||||
multi_person: bool,
|
||||
word_timing: bool,
|
||||
) -> Result<String, String> {
|
||||
let payload: ApplePayload = json::decode(raw)
|
||||
.map_err(|error| format!("invalid SpotiFLAC-Mobile Lyrics response: {error}"))?;
|
||||
let kind = json::field_name(&payload.kind);
|
||||
if kind == "unsynced" && !payload.plain.trim().is_empty() {
|
||||
return Ok(payload.plain.trim().into());
|
||||
}
|
||||
if !matches!(kind.as_str(), "syllable" | "line" | "unsynced") {
|
||||
return Err("unsupported SpotiFLAC-Mobile Lyrics sync type".into());
|
||||
}
|
||||
let lines = payload
|
||||
.content
|
||||
.as_deref()
|
||||
.ok_or("missing SpotiFLAC-Mobile Lyrics content")?;
|
||||
let timed = kind != "unsynced";
|
||||
let words = word_timing && kind == "syllable";
|
||||
let mut output = String::new();
|
||||
for line in lines {
|
||||
let details = line.text.as_deref().unwrap_or_default();
|
||||
if !details.iter().any(|detail| !detail.text.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
if timed {
|
||||
output.push_str(&lrc::timestamp(line.timestamp as i64));
|
||||
if multi_person {
|
||||
output.push_str(if line.opposite_turn { "v2:" } else { "v1:" });
|
||||
}
|
||||
}
|
||||
append_detail(&mut output, details, words);
|
||||
if timed && multi_person {
|
||||
for background in line.background_lines.as_deref().unwrap_or_default() {
|
||||
let details = background.text.as_deref().unwrap_or_default();
|
||||
if !details.iter().any(|detail| !detail.text.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
output.push_str("\n[bg:");
|
||||
// Group bounds retain backing timing even in line-only mode.
|
||||
output.push_str(&format!(
|
||||
"<{}>",
|
||||
lrc::timestamp_inline(background.timestamp as i64)
|
||||
));
|
||||
append_detail(&mut output, details, words);
|
||||
output.push_str(&format!(
|
||||
"<{}>]",
|
||||
lrc::timestamp_inline(background.endtime as i64)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(if timed && multi_person {
|
||||
apple_vocal_sides(output.trim(), &payload)
|
||||
} else {
|
||||
output.trim().into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn format_apple(raw: &str, multi_person: bool, word_timing: bool) -> Result<String, String> {
|
||||
if let Ok(Some(value)) = json::decode::<Option<String>>(raw)
|
||||
&& !value.trim().is_empty()
|
||||
@@ -577,6 +646,78 @@ pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: boo
|
||||
mod supplement_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spotiflac_apple_preserves_syllables_and_separate_background_groups() {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.00]Lead\n[00:02.00]Echo",
|
||||
"content": [{
|
||||
"timestamp": 1009, "endtime": 4000, "agent": "v3", "oppositeTurn": true,
|
||||
"text": [
|
||||
{"text": "To", "part": true, "timestamp": 1009, "endtime": 1500},
|
||||
{"text": "gether", "timestamp": 1500, "endtime": 4000}
|
||||
],
|
||||
"backgroundLines": [
|
||||
{"timestamp": 2000, "endtime": 2500, "text": [
|
||||
{"text": "Echo", "timestamp": 2000, "endtime": 2500}
|
||||
]},
|
||||
{"timestamp": 3000, "endtime": 3500, "text": [
|
||||
{"text": "Again", "timestamp": 3000, "endtime": 3500}
|
||||
]}
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
for words in [false, true] {
|
||||
let text = format_spotiflac_apple(&raw, true, words).unwrap();
|
||||
assert!(text.starts_with("[00:01.009]v1:"));
|
||||
assert_eq!(text.matches("[bg:").count(), 2);
|
||||
let lyrics = LyricsResponse::from_text(
|
||||
&text,
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
);
|
||||
assert_eq!(lyrics.lines().len(), 1);
|
||||
assert_eq!(lyrics.lines()[0].start_time_ms, 1009);
|
||||
if words {
|
||||
assert!(text.contains("To<00:01.50><00:01.50>gether"));
|
||||
} else {
|
||||
assert!(text.contains("Together"));
|
||||
}
|
||||
let solo = format_spotiflac_apple(&raw, false, words).unwrap();
|
||||
assert!(!solo.contains("[bg:"));
|
||||
assert!(!solo.contains("v1:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spotiflac_apple_handles_line_only_unsynced_and_invalid_responses() {
|
||||
let mut raw = serde_json::json!({
|
||||
"type": "Line", "content": [{
|
||||
"timestamp": 1500, "text": [{"text": "Whole"}, {"text": "line"}]
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
format_spotiflac_apple(&raw.to_string(), false, true).unwrap(),
|
||||
"[00:01.50]Whole line"
|
||||
);
|
||||
raw["type"] = "Unsynced".into();
|
||||
raw["content"][0]["timestamp"] = serde_json::Value::Null;
|
||||
let text = format_spotiflac_apple(&raw.to_string(), true, true).unwrap();
|
||||
assert_eq!(text, "Whole line");
|
||||
assert_eq!(
|
||||
LyricsResponse::from_text(&text, "Example", "Example").sync_type,
|
||||
"UNSYNCED"
|
||||
);
|
||||
for raw in [
|
||||
"<html>Error</html>",
|
||||
r#"{"error":{"code":"APPLE_AUTH_REQUIRED"}}"#,
|
||||
"{}",
|
||||
] {
|
||||
assert!(format_spotiflac_apple(raw, true, true).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_agents_restore_vocal_sides_without_reformatting_word_times() {
|
||||
let mut raw = serde_json::json!({
|
||||
|
||||
@@ -10,6 +10,31 @@ static INDEX: LazyLock<Regex> =
|
||||
static TOKEN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap());
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum AppleLyricsSource {
|
||||
Paxsenix,
|
||||
SpotiFlac,
|
||||
}
|
||||
|
||||
fn apple_with_fallback(
|
||||
check: &Check<'_>,
|
||||
mut fetch: impl FnMut(AppleLyricsSource) -> Result<LyricsResponse, LyricsError>,
|
||||
) -> Result<LyricsResponse, LyricsError> {
|
||||
check().map_err(LyricsError::Cancelled)?;
|
||||
match fetch(AppleLyricsSource::Paxsenix) {
|
||||
Err(primary @ (LyricsError::Unavailable(_) | LyricsError::Other(_))) => {
|
||||
check().map_err(LyricsError::Cancelled)?;
|
||||
match fetch(AppleLyricsSource::SpotiFlac) {
|
||||
// An outage followed by a missing backup must not be cached
|
||||
// as proof that the primary has no lyrics for this song.
|
||||
Err(LyricsError::NotFound(_)) => Err(primary),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
impl BuiltinLyricsClient {
|
||||
fn lock_apple_token<'a>(
|
||||
&'a self,
|
||||
@@ -141,14 +166,44 @@ impl BuiltinLyricsClient {
|
||||
})
|
||||
.filter(|(id, _)| !id.trim().is_empty())
|
||||
.ok_or_else(|| LyricsError::NotFound("no songs found on apple music".into()))?;
|
||||
let mut get = Request::new("https://lyrics.paxsenix.org/apple-music/lyrics");
|
||||
get.timeout = Duration::from_secs(20);
|
||||
get.params = pairs(&[("id", selected.0.trim())]);
|
||||
let raw = text_from_bytes(&self.http.get(get, check)?.body);
|
||||
apple_with_fallback(check, |source| {
|
||||
self.apple_lyrics(selected.0.trim(), source, request, check)
|
||||
})
|
||||
}
|
||||
|
||||
fn apple_lyrics(
|
||||
&self,
|
||||
id: &str,
|
||||
source: AppleLyricsSource,
|
||||
request: &SearchRequest,
|
||||
check: &Check<'_>,
|
||||
) -> Result<LyricsResponse, LyricsError> {
|
||||
let mut get = match source {
|
||||
AppleLyricsSource::Paxsenix => {
|
||||
let mut get = Request::new("https://lyrics.paxsenix.org/apple-music/lyrics");
|
||||
get.timeout = Duration::from_secs(20);
|
||||
get.params = pairs(&[("id", id)]);
|
||||
get
|
||||
}
|
||||
AppleLyricsSource::SpotiFlac => {
|
||||
let mut get = Request::new("https://api.zarz.moe/apple-music/lyrics");
|
||||
get.timeout = Duration::from_secs(65);
|
||||
// Let the backup use its account's storefront, since lyric
|
||||
// availability differs between catalog regions.
|
||||
get.params = pairs(&[("id", id), ("v", "1")]);
|
||||
get
|
||||
}
|
||||
};
|
||||
get.allowed = &[200, 404];
|
||||
let response = self.http.get(get, check)?;
|
||||
if response.status == 404 {
|
||||
return Err(LyricsError::NotFound("apple music lyrics not found".into()));
|
||||
}
|
||||
let raw = text_from_bytes(&response.body);
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err(LyricsError::Other(
|
||||
"empty lyrics response from apple music".into(),
|
||||
if raw.is_empty() || raw.starts_with('<') {
|
||||
return Err(LyricsError::Unavailable(
|
||||
"invalid lyrics response from apple music".into(),
|
||||
));
|
||||
}
|
||||
if let Some(message) = errors::detect_payload(raw) {
|
||||
@@ -157,16 +212,26 @@ impl BuiltinLyricsClient {
|
||||
format!("apple music proxy returned non-lyric payload: {message}"),
|
||||
));
|
||||
}
|
||||
let text = match payloads::format_apple(
|
||||
raw,
|
||||
request.options.multi_person_word_by_word,
|
||||
request.options.apple_elrc_word_sync,
|
||||
) {
|
||||
Ok(text) => text,
|
||||
Err(error) if raw.starts_with(['{', '[']) => return Err(LyricsError::Other(error)),
|
||||
Err(_) => raw.into(),
|
||||
let multi_person = request.options.multi_person_word_by_word;
|
||||
let word_timing = request.options.apple_elrc_word_sync;
|
||||
let (text, credit) = match source {
|
||||
AppleLyricsSource::Paxsenix => {
|
||||
let text = match payloads::format_apple(raw, multi_person, word_timing) {
|
||||
Ok(text) => text,
|
||||
Err(error) if raw.starts_with(['{', '[']) => {
|
||||
return Err(LyricsError::Unavailable(error));
|
||||
}
|
||||
Err(_) => raw.into(),
|
||||
};
|
||||
(text, "Apple Music")
|
||||
}
|
||||
AppleLyricsSource::SpotiFlac => (
|
||||
payloads::format_spotiflac_apple(raw, multi_person, word_timing)
|
||||
.map_err(LyricsError::Unavailable)?,
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
),
|
||||
};
|
||||
let mut lyrics = from_text(&text, "Apple Music", "Apple Music")?;
|
||||
let mut lyrics = from_text(&text, "Apple Music", credit)?;
|
||||
payloads::apple_supplements(raw, &mut lyrics);
|
||||
Ok(lyrics)
|
||||
}
|
||||
@@ -184,6 +249,123 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_success_never_calls_backup() {
|
||||
let expected = LyricsResponse::from_text("[00:01.00]Example", "Apple Music", "Apple Music");
|
||||
let mut calls = Vec::new();
|
||||
let actual = apple_with_fallback(&|| Ok(()), |source| {
|
||||
calls.push(source);
|
||||
Ok(expected.clone())
|
||||
});
|
||||
assert_eq!(actual, Ok(expected));
|
||||
assert_eq!(calls, [AppleLyricsSource::Paxsenix]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outages_try_backup_after_primary_and_keep_source_credit() {
|
||||
for error in [
|
||||
LyricsError::Unavailable("timeout".into()),
|
||||
LyricsError::Unavailable("HTTP 503".into()),
|
||||
LyricsError::Other("HTTP 403".into()),
|
||||
LyricsError::Unavailable("invalid payload".into()),
|
||||
] {
|
||||
let mut calls = Vec::new();
|
||||
let result = apple_with_fallback(&|| Ok(()), |source| {
|
||||
calls.push(source);
|
||||
match source {
|
||||
AppleLyricsSource::Paxsenix => Err(error.clone()),
|
||||
AppleLyricsSource::SpotiFlac => Ok(LyricsResponse::from_text(
|
||||
"[00:01.00]Example",
|
||||
"Apple Music",
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
)),
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
calls,
|
||||
[AppleLyricsSource::Paxsenix, AppleLyricsSource::SpotiFlac]
|
||||
);
|
||||
assert_eq!(result.provider, "Apple Music");
|
||||
assert_eq!(result.source, "SpotiFLAC-Mobile Lyrics");
|
||||
let saved = spotiflac_core::lyrics::lrc::with_metadata(&result, "Track", "Artist");
|
||||
assert!(!saved.contains("Paxsenix"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_lyrics_and_cancellation_do_not_start_backup() {
|
||||
for error in [
|
||||
LyricsError::NotFound("no lyrics".into()),
|
||||
LyricsError::Cancelled("cancelled".into()),
|
||||
LyricsError::Recursive("recursive".into()),
|
||||
] {
|
||||
let mut calls = Vec::new();
|
||||
let result = apple_with_fallback(&|| Ok(()), |source| {
|
||||
calls.push(source);
|
||||
Err(error.clone())
|
||||
});
|
||||
assert_eq!(result, Err(error));
|
||||
assert_eq!(calls, [AppleLyricsSource::Paxsenix]);
|
||||
}
|
||||
let checks = AtomicUsize::new(0);
|
||||
let mut calls = Vec::new();
|
||||
let result = apple_with_fallback(
|
||||
&|| {
|
||||
if checks.fetch_add(1, Ordering::AcqRel) > 0 {
|
||||
Err("cancel before backup".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|source| {
|
||||
calls.push(source);
|
||||
Err(LyricsError::Unavailable("timeout".into()))
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(LyricsError::Cancelled("cancel before backup".into()))
|
||||
);
|
||||
assert_eq!(calls, [AppleLyricsSource::Paxsenix]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_backup_does_not_turn_primary_outage_into_negative_cache() {
|
||||
let result = apple_with_fallback(&|| Ok(()), |source| match source {
|
||||
AppleLyricsSource::Paxsenix => Err(LyricsError::Unavailable("HTTP 503".into())),
|
||||
AppleLyricsSource::SpotiFlac => Err(LyricsError::NotFound("no lyrics".into())),
|
||||
});
|
||||
assert_eq!(result, Err(LyricsError::Unavailable("HTTP 503".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires live Apple catalog and SpotiFLAC-Mobile Lyrics access"]
|
||||
fn live_apple_backup_after_primary_failure() {
|
||||
let network = NetworkService::new().unwrap();
|
||||
let client = BuiltinLyricsClient::with_endpoints(
|
||||
&network,
|
||||
"",
|
||||
Arc::new(Resolver),
|
||||
BTreeMap::from([(
|
||||
"https://lyrics.paxsenix.org".into(),
|
||||
"https://127.0.0.1:9".into(),
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
let request = SearchRequest {
|
||||
track: "Never Gonna Give You Up".into(),
|
||||
artist: "Rick Astley".into(),
|
||||
duration: 213.0,
|
||||
..SearchRequest::default()
|
||||
};
|
||||
let lyrics = client.apple(&request, &|| Ok(())).unwrap();
|
||||
assert!(lyrics.has_usable_text());
|
||||
assert!(lyrics.is_synced());
|
||||
assert_eq!(lyrics.provider, "Apple Music");
|
||||
assert_eq!(lyrics.source, "SpotiFLAC-Mobile Lyrics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_for_another_token_refresh_remains_cancellable() {
|
||||
let network = NetworkService::new().unwrap();
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
pub const MAX_ENTRIES: usize = 500;
|
||||
pub const TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const MAX_PERSISTED_BYTES: u64 = 64 << 20;
|
||||
const SNAPSHOT_VERSION: u32 = 5;
|
||||
const SNAPSHOT_VERSION: u32 = 6;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry {
|
||||
@@ -145,9 +145,8 @@ impl LyricsCache {
|
||||
let Some(response) = entry.response else {
|
||||
continue;
|
||||
};
|
||||
// Older versions lost Apple supplements or collapsed group vocals
|
||||
// into the second singer instead of preserving the vocal side.
|
||||
// Refetch once, preserving other providers' caches.
|
||||
// Refetch legacy Apple results once to apply current source
|
||||
// priority and vocal timing, preserving other providers' caches.
|
||||
if loaded.version < SNAPSHOT_VERSION && response.provider == "Apple Music" {
|
||||
continue;
|
||||
}
|
||||
@@ -333,8 +332,11 @@ mod tests {
|
||||
fn current_snapshot_restores_supplementary_text() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(100);
|
||||
let mut lyrics =
|
||||
LyricsResponse::from_text("[00:01.009]Original", "Apple Music", "Apple Music");
|
||||
let mut lyrics = LyricsResponse::from_text(
|
||||
"[00:01.009]Original",
|
||||
"Apple Music",
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
);
|
||||
let line = &mut lyrics.lines.as_mut().unwrap()[0];
|
||||
line.romanization = Some("Romanized".into());
|
||||
line.romanization_words = Some(vec![spotiflac_core::lyrics::LyricsWord {
|
||||
|
||||
Reference in new issue
Block a user