-21
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failed!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1"
|
||||
}
|
||||
+14
-2
@@ -6,12 +6,24 @@
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "metadata_refreshed_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+91
-13
@@ -6,67 +6,145 @@
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tvdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "original_language"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "root_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "auto_track"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "upstream_ended"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "metadata_refreshed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "backdrop_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "backdrop_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
"type_info": "Float",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "vote_average"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failures!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
},
|
||||
{
|
||||
"name": "last_failed_at?: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
//! while a season is airing, grab per episode. A completed season with no
|
||||
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
|
||||
//! encode. A pack that hard-failed must not cost the whole season, so the
|
||||
//! season falls back to per-episode instead of being blacklisted outright.
|
||||
//! season falls back to per-episode while the failure's §6.2 backoff window
|
||||
//! is open, and retries the pack once it elapses — quiet, never off.
|
||||
//!
|
||||
//! Re-grabbing a pack once an airing season completes is deliberately not
|
||||
//! done (§14): a season with any episode already on disk grabs per episode.
|
||||
@@ -31,8 +32,9 @@ pub struct SeasonGrabFacts<'a> {
|
||||
/// re-grabs a pack over episodes on disk, and a pack must not re-import
|
||||
/// what exists).
|
||||
pub any_episode_on_disk: bool,
|
||||
/// Whether a season-pack grab for this season already hard-failed.
|
||||
pub pack_hard_failed: bool,
|
||||
/// Whether a failed season-pack grab still holds the season off the
|
||||
/// pack lane — true only while the §6.2 backoff window is open.
|
||||
pub pack_backoff_active: bool,
|
||||
}
|
||||
|
||||
/// Picks the grab mode for one season.
|
||||
@@ -49,7 +51,7 @@ pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
||||
.iter()
|
||||
.all(|date| date.is_some_and(|date| date <= facts.now));
|
||||
|
||||
if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed {
|
||||
if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active {
|
||||
SeasonGrabMode::SeasonPack
|
||||
} else {
|
||||
SeasonGrabMode::PerEpisode
|
||||
@@ -69,7 +71,7 @@ mod tests {
|
||||
air_dates,
|
||||
now: SystemTime::UNIX_EPOCH + 100 * DAY,
|
||||
any_episode_on_disk: false,
|
||||
pack_hard_failed: false,
|
||||
pack_backoff_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +107,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() {
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut facts = facts(&aired);
|
||||
facts.pack_hard_failed = true;
|
||||
facts.pack_backoff_active = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ use arr_dl::TransmissionClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
use crate::grab::{
|
||||
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
|
||||
SeedingRules,
|
||||
backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope,
|
||||
GrabTarget, Grabber, SeedingRules,
|
||||
};
|
||||
use crate::indexers::IndexerDirectory;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
@@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
let pack_hard_failed = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
||||
) AS "failed!: bool""#,
|
||||
let failed_packs = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
@@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
.collect::<Vec<_>>(),
|
||||
now: std::time::SystemTime::now(),
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
|
||||
pack_hard_failed,
|
||||
pack_backoff_active: failed_packs.failures > 0
|
||||
&& !backoff_elapsed(
|
||||
failed_packs.failures,
|
||||
failed_packs.last_failed_at.as_deref(),
|
||||
),
|
||||
}) == SeasonGrabMode::SeasonPack)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ impl TvGrabAction {
|
||||
&season,
|
||||
&blacklist,
|
||||
EPISODE_SEARCHES_PER_TICK - episode_searches,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -102,12 +103,15 @@ impl TvGrabAction {
|
||||
|
||||
/// Search and grab for one season. `None` means the season was skipped
|
||||
/// without spending an indexer call — nothing due, or no language yet.
|
||||
/// `ignore_pack_backoff` is the manual trigger's "try again now": it
|
||||
/// waives the failed-pack backoff window for this one invocation.
|
||||
async fn grab_season(
|
||||
&self,
|
||||
database: &Db,
|
||||
season: &PendingSeason,
|
||||
blacklist: &Blacklist,
|
||||
episode_budget: usize,
|
||||
ignore_pack_backoff: bool,
|
||||
) -> Result<Option<SeasonWork>, GrabError> {
|
||||
// §5.2: without the title's original language there is nothing to
|
||||
// evaluate a release against, and guessing is worse than waiting.
|
||||
@@ -141,7 +145,8 @@ impl TvGrabAction {
|
||||
air_dates: &air_dates,
|
||||
now,
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
|
||||
pack_hard_failed: pack_hard_failed(database, season.season_id).await?,
|
||||
pack_backoff_active: !ignore_pack_backoff
|
||||
&& pack_backoff_active(database, season.season_id).await?,
|
||||
});
|
||||
|
||||
let aired_due_gaps: Vec<&SeasonEpisode> = gaps
|
||||
@@ -566,8 +571,10 @@ impl TvGrabAction {
|
||||
}
|
||||
|
||||
/// The manual season trigger (issue #125's deck, drained per #132):
|
||||
/// reset every open episode's backoff and run the same pack-or-fall-back
|
||||
/// lane as the tick, scoped to this one season with no tick budgets.
|
||||
/// reset every open episode's backoff, waive the failed-pack backoff
|
||||
/// (§6.2's escape hatch is this deck), and run the same
|
||||
/// pack-or-fall-back lane as the tick, scoped to this one season with
|
||||
/// no tick budgets.
|
||||
///
|
||||
/// A satisfied season — nothing open, or a grab already in flight —
|
||||
/// gets its deck refreshed and no grab; a blocked series refuses.
|
||||
@@ -625,7 +632,7 @@ impl TvGrabAction {
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX)
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX, true)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1106,19 +1113,21 @@ async fn load_season_release(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether a season-pack grab for this season already hard-failed — the
|
||||
/// fall-back-to-per-episode signal.
|
||||
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let failed = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
||||
) AS "failed!: bool""#,
|
||||
/// Whether failed season-pack grabs still hold this season off the pack
|
||||
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
||||
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
||||
/// latest failed grab's `grabbed_at` — failure time itself is not recorded.
|
||||
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
Ok(failed)
|
||||
Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref()))
|
||||
}
|
||||
|
||||
/// A pack for exactly this season. Multi-season packs are never selected
|
||||
@@ -1463,38 +1472,41 @@ mod tests {
|
||||
/// The third acceptance case, selection side: a hard-failed pack put the
|
||||
/// release on the blacklist and the season falls back to per-episode —
|
||||
/// the pack is not tried again and the episodes are not written off.
|
||||
#[tokio::test]
|
||||
async fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
// What the import tick left behind: a failed season grab and the
|
||||
// pack release on the blacklist (§6.3).
|
||||
/// Seed what the import tick leaves behind after a pack fails: one
|
||||
/// `failed` season grab per (infohash, age) pair.
|
||||
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
||||
'https://tracker/pack.torrent', '{}', 'eligible')
|
||||
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||
'https://tracker/oldpack.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, 'season', ?, 'dead', 'failed')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
arr_db::blacklist::add(
|
||||
database.pool(),
|
||||
Some("dead"),
|
||||
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos",
|
||||
"dolby_vision_profile",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (index, age) in ages.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, 'season', ?, ?, 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.bind(format!("dead{index}"))
|
||||
.bind(age)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed pack inside its §6.2 backoff window stays per-episode: the
|
||||
/// window quiets the pack lane, per-episode still makes progress.
|
||||
#[tokio::test]
|
||||
async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
@@ -1520,6 +1532,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2: one failed pack backs the pack lane off for 1h, then the tick
|
||||
/// retries the pack instead of never trying again.
|
||||
#[tokio::test]
|
||||
async fn a_failed_pack_retries_after_its_backoff_window() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-2 hours"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await.last().unwrap(),
|
||||
&("season".to_owned(), season_id, "sent".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
/// Repeated failures ride the capped curve: five failed packs mean a 7d
|
||||
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
||||
#[tokio::test]
|
||||
async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() {
|
||||
let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(
|
||||
&inside.1,
|
||||
inside.2,
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"],
|
||||
)
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
action(&indexer, &downloader).tick(&inside.1).await.unwrap();
|
||||
assert!(
|
||||
fake.torrents()
|
||||
.iter()
|
||||
.all(|torrent| !torrent.source.ends_with("pack.torrent")),
|
||||
"6 days into a 7d window the pack lane stays quiet"
|
||||
);
|
||||
|
||||
let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(
|
||||
&elapsed.1,
|
||||
elapsed.2,
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
|
||||
)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
action(&indexer, &downloader)
|
||||
.tick(&elapsed.1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
}
|
||||
|
||||
/// The escape hatch (§6.2, issue #181): a manual season search waives
|
||||
/// the failed-pack backoff and retries the pack right now.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_search_waives_the_pack_backoff() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await.last().unwrap(),
|
||||
&("season".to_owned(), season_id, "sent".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
}
|
||||
|
||||
/// §14: a season with an episode already on disk never grabs the pack,
|
||||
/// even when fully released. The remaining episodes come individually.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user