mirror of
https://github.com/RetroGameSets/RGSX.git
synced 2026-05-12 02:37:09 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce39722351 | ||
|
|
a7dad84108 | ||
|
|
c9f48d20dd | ||
|
|
cd7795f70e |
@@ -27,7 +27,7 @@ except Exception:
|
||||
pygame = None # type: ignore
|
||||
|
||||
# Version actuelle de l'application
|
||||
app_version = "2.6.1.5"
|
||||
app_version = "2.6.1.7"
|
||||
|
||||
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
|
||||
GAMELIST_UPDATE_DAYS = 1
|
||||
|
||||
@@ -19,7 +19,7 @@ from utils import (
|
||||
restart_application, generate_support_zip, load_sources,
|
||||
ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string,
|
||||
start_connection_status_check, get_clean_display_name, get_existing_history_matches,
|
||||
move_files_to_directory
|
||||
move_files_to_directory, parse_torrent_download_url
|
||||
)
|
||||
from history import load_history, clear_history, add_to_history, save_history, scan_roms_for_downloaded_games
|
||||
from language import _, get_available_languages, set_language
|
||||
@@ -52,6 +52,10 @@ def _notify_torrent_in_maintenance(game_name: str | None = None) -> None:
|
||||
|
||||
def _has_download_url(url, game_name: str | None = None) -> bool:
|
||||
if isinstance(url, str) and url.strip():
|
||||
if parse_torrent_download_url(url) is not None:
|
||||
_notify_torrent_in_maintenance(game_name)
|
||||
config.needs_redraw = True
|
||||
return False
|
||||
return True
|
||||
|
||||
_notify_torrent_in_maintenance(game_name)
|
||||
@@ -1762,6 +1766,9 @@ def handle_controls(event, sources, joystick, screen):
|
||||
is_zip_non_supported = pending_download[3] if len(pending_download) > 3 else False
|
||||
|
||||
if is_1fichier_url(url):
|
||||
ensure_download_provider_keys(False)
|
||||
if missing_all_provider_keys():
|
||||
logger.warning("Aucune clé API - Mode gratuit 1fichier sera utilisé (attente requise)")
|
||||
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported, task_id))
|
||||
else:
|
||||
task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id))
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"free_mode_submitting": "[Kostenloser Modus] Formular wird gesendet...",
|
||||
"free_mode_link_found": "[Kostenloser Modus] Link gefunden: {0}...",
|
||||
"free_mode_completed": "[Kostenloser Modus] Abgeschlossen: {0}",
|
||||
"free_mode_guest_slots_unavailable": "1fichier: Der kostenlose Gast-Download ist vorübergehend nicht verfügbar (alle Slots sind belegt). Bitte versuchen Sie es später erneut.",
|
||||
"free_mode_unavailable_in_app": "1fichier: Dieser Download ist derzeit in der Anwendung nicht verfügbar. Bitte versuchen Sie es später erneut.",
|
||||
"free_mode_premium_advice": "Für unbegrenzte Downloads jederzeit und mit voller Geschwindigkeit benötigen Sie ein Premium-Konto oder einen Debrid-Dienst und müssen dessen API-Schlüssel in RGSX eintragen.",
|
||||
"download_status": "{0}: {1}",
|
||||
"download_canceled": "Download vom Benutzer abgebrochen.",
|
||||
"download_removed_from_queue": "Aus der Download-Warteschlange entfernt",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"free_mode_submitting": "[Free mode] Submitting form...",
|
||||
"free_mode_link_found": "[Free mode] Link found: {0}...",
|
||||
"free_mode_completed": "[Free mode] Completed: {0}",
|
||||
"free_mode_guest_slots_unavailable": "1fichier: free guest download is temporarily unavailable (all slots are currently in use). Please try again later.",
|
||||
"free_mode_unavailable_in_app": "1fichier: this download is not available in the application right now. Please try again later.",
|
||||
"free_mode_premium_advice": "For unlimited, on-demand, full-speed downloads, you need a premium account or debrid service and must enter its API key in RGSX.",
|
||||
"download_status": "{0}: {1}",
|
||||
"download_canceled": "Download canceled by user.",
|
||||
"download_removed_from_queue": "Removed from download queue",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"free_mode_submitting": "[Modo gratuito] Enviando formulario...",
|
||||
"free_mode_link_found": "[Modo gratuito] Enlace encontrado: {0}...",
|
||||
"free_mode_completed": "[Modo gratuito] Completado: {0}",
|
||||
"free_mode_guest_slots_unavailable": "1fichier: la descarga gratuita como invitado no está disponible temporalmente (todos los cupos están ocupados). Inténtelo de nuevo más tarde.",
|
||||
"free_mode_unavailable_in_app": "1fichier: esta descarga no está disponible en la aplicación en este momento. Inténtelo de nuevo más tarde.",
|
||||
"free_mode_premium_advice": "Para descargar de forma ilimitada, cuando quiera y a máxima velocidad, necesita una cuenta premium o un desbridizador y debe introducir su clave API en RGSX.",
|
||||
"download_status": "{0}: {1}",
|
||||
"download_canceled": "Descarga cancelada por el usuario.",
|
||||
"download_removed_from_queue": "Eliminado de la cola de descarga",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"free_mode_submitting": "[Mode gratuit] Soumission formulaire...",
|
||||
"free_mode_link_found": "[Mode gratuit] Lien trouvé: {0}...",
|
||||
"free_mode_completed": "[Mode gratuit] Terminé: {0}",
|
||||
"free_mode_guest_slots_unavailable": "1fichier : le téléchargement gratuit invité est temporairement indisponible (tous les créneaux sont occupés). Réessayez plus tard.",
|
||||
"free_mode_unavailable_in_app": "1fichier : ce téléchargement n'est pas disponible dans l'application pour le moment. Réessayez plus tard.",
|
||||
"free_mode_premium_advice": "Pour télécharger de manière illimitée, quand vous voulez et à pleine vitesse, vous devez obtenir un compte premium ou un débrideur et entrer votre clé API dans RGSX.",
|
||||
"download_status": "{0} : {1}",
|
||||
"download_canceled": "Téléchargement annulé par l'utilisateur.",
|
||||
"download_removed_from_queue": "Retiré de la file de téléchargement",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"free_mode_submitting": "[Modalità gratuita] Invio modulo...",
|
||||
"free_mode_link_found": "[Modalità gratuita] Link trovato: {0}...",
|
||||
"free_mode_completed": "[Modalità gratuita] Completato: {0}",
|
||||
"free_mode_guest_slots_unavailable": "1fichier: il download gratuito come ospite non è temporaneamente disponibile (tutti gli slot sono occupati). Riprova più tardi.",
|
||||
"free_mode_unavailable_in_app": "1fichier: questo download non è disponibile nell'applicazione in questo momento. Riprova più tardi.",
|
||||
"free_mode_premium_advice": "Per scaricare senza limiti, quando vuoi e alla massima velocità, hai bisogno di un account premium o di un servizio debrid e devi inserire la sua chiave API in RGSX.",
|
||||
"download_status": "{0}: {1}",
|
||||
"download_canceled": "Download annullato dall'utente.",
|
||||
"download_removed_from_queue": "Rimosso dalla coda di download",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"free_mode_submitting": "[Modo gratuito] Enviando formulário...",
|
||||
"free_mode_link_found": "[Modo gratuito] Link encontrado: {0}...",
|
||||
"free_mode_completed": "[Modo gratuito] Concluído: {0}",
|
||||
"free_mode_guest_slots_unavailable": "1fichier: o download gratuito como convidado está temporariamente indisponível (todos os slots estão ocupados). Tente novamente mais tarde.",
|
||||
"free_mode_unavailable_in_app": "1fichier: este download não está disponível no aplicativo no momento. Tente novamente mais tarde.",
|
||||
"free_mode_premium_advice": "Para baixar sem limites, quando quiser e em velocidade máxima, você precisa de uma conta premium ou de um serviço debrid e deve inserir a chave API no RGSX.",
|
||||
"download_status": "{0}: {1}",
|
||||
"download_canceled": "Download cancelado pelo usuário.",
|
||||
"download_removed_from_queue": "Removido da fila de download",
|
||||
|
||||
@@ -36,6 +36,7 @@ import html as html_module
|
||||
from urllib.parse import urljoin, unquote
|
||||
import urllib.parse
|
||||
import tempfile
|
||||
import unicodedata
|
||||
|
||||
|
||||
|
||||
@@ -383,6 +384,92 @@ def extract_wait_seconds_1f(html_text):
|
||||
return seconds
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_visible_text_from_html(html_text: str) -> str:
|
||||
if not html_text:
|
||||
return ""
|
||||
text = re.sub(r'(?is)<script[^>]*>.*?</script>', ' ', html_text)
|
||||
text = re.sub(r'(?is)<style[^>]*>.*?</style>', ' ', text)
|
||||
text = re.sub(r'(?is)<[^>]+>', ' ', text)
|
||||
text = html_module.unescape(text).replace('\xa0', ' ')
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
|
||||
def _normalize_1fichier_text(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
normalized = unicodedata.normalize("NFKD", text)
|
||||
normalized = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
return re.sub(r'\s+', ' ', normalized).strip().lower()
|
||||
|
||||
|
||||
def _translate_free_mode_message(key: str, fallback: str) -> str:
|
||||
try:
|
||||
translated = _(key)
|
||||
if translated and translated != key:
|
||||
return translated
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
def _append_1fichier_upgrade_advice(message: str) -> str:
|
||||
advice = _translate_free_mode_message(
|
||||
"free_mode_premium_advice",
|
||||
"For unlimited, on-demand, full-speed downloads, you need a premium account or debrid service and must enter its API key in RGSX.",
|
||||
)
|
||||
base_message = (message or "").strip()
|
||||
if not base_message:
|
||||
return advice
|
||||
return f"{base_message}\n{advice}"
|
||||
|
||||
|
||||
def _extract_1fichier_free_mode_block_reason(html_text: str) -> str | None:
|
||||
visible_text = _extract_visible_text_from_html(html_text)
|
||||
normalized = _normalize_1fichier_text(visible_text)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
if (
|
||||
"telechargement gratuit est temporairement limite" in normalized
|
||||
and "identifiez-vous immediatement" in normalized
|
||||
):
|
||||
return _append_1fichier_upgrade_advice(
|
||||
_translate_free_mode_message(
|
||||
"free_mode_guest_slots_unavailable",
|
||||
"1fichier: free guest download is temporarily unavailable (all slots are currently in use). Please try again later.",
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
"free download is temporarily limited" in normalized
|
||||
and "all free slots for guests are currently used" in normalized
|
||||
):
|
||||
return _append_1fichier_upgrade_advice(
|
||||
_translate_free_mode_message(
|
||||
"free_mode_guest_slots_unavailable",
|
||||
"1fichier: free guest download is temporarily unavailable (all slots are currently in use). Please try again later.",
|
||||
)
|
||||
)
|
||||
|
||||
if "identifiez-vous immediatement pour continuer votre telechargement" in normalized:
|
||||
return _append_1fichier_upgrade_advice(
|
||||
_translate_free_mode_message(
|
||||
"free_mode_unavailable_in_app",
|
||||
"1fichier: this download is not available in the application right now. Please try again later.",
|
||||
)
|
||||
)
|
||||
|
||||
if "sign in immediately to continue your download" in normalized:
|
||||
return _append_1fichier_upgrade_advice(
|
||||
_translate_free_mode_message(
|
||||
"free_mode_unavailable_in_app",
|
||||
"1fichier: this download is not available in the application right now. Please try again later.",
|
||||
)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progress_callback=None, wait_callback=None, cancel_event=None):
|
||||
"""
|
||||
Télécharge un fichier depuis 1fichier.com en mode gratuit (sans API key).
|
||||
@@ -434,6 +521,7 @@ def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progr
|
||||
r = session.get(url, allow_redirects=True, timeout=30)
|
||||
r.raise_for_status()
|
||||
html = r.text
|
||||
page_url = str(r.url)
|
||||
|
||||
# 2. Détection compte à rebours
|
||||
wait_s = extract_wait_seconds_1f(html)
|
||||
@@ -462,8 +550,12 @@ def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progr
|
||||
|
||||
name_m = re.search(r'name=[\"\']([^\"\']+)', inp)
|
||||
value_m = re.search(r'value=[\"\']([^\"\']*)', inp)
|
||||
type_m = re.search(r'type=["\']([^"\']+)', inp, re.IGNORECASE)
|
||||
|
||||
if name_m:
|
||||
input_type = type_m.group(1).strip().lower() if type_m else 'text'
|
||||
if input_type in {'checkbox', 'radio'} and 'checked' not in inp.lower():
|
||||
continue
|
||||
name = name_m.group(1)
|
||||
value = value_m.group(1) if value_m else ''
|
||||
data[name] = html_module.unescape(value)
|
||||
@@ -478,9 +570,15 @@ def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progr
|
||||
while post_attempt < max_post_attempts:
|
||||
post_attempt += 1
|
||||
try:
|
||||
r2 = session.post(str(r.url), data=data, allow_redirects=True, timeout=30)
|
||||
parsed_page = urllib.parse.urlparse(page_url)
|
||||
post_headers = {
|
||||
'Referer': page_url,
|
||||
'Origin': f"{parsed_page.scheme}://{parsed_page.netloc}" if parsed_page.scheme and parsed_page.netloc else page_url,
|
||||
}
|
||||
r2 = session.post(page_url, data=data, headers=post_headers, allow_redirects=True, timeout=30)
|
||||
r2.raise_for_status()
|
||||
html = r2.text
|
||||
page_url = str(r2.url)
|
||||
except Exception as pe:
|
||||
logger.debug(f"1fichier: POST attempt {post_attempt} failed: {pe}")
|
||||
if post_attempt >= max_post_attempts:
|
||||
@@ -505,6 +603,11 @@ def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progr
|
||||
|
||||
if html is None:
|
||||
return (False, None, "Erreur lors de la soumission du formulaire")
|
||||
|
||||
blocked_reason = _extract_1fichier_free_mode_block_reason(html)
|
||||
if blocked_reason:
|
||||
logger.warning(f"1fichier: free mode blocked after form submit: {blocked_reason}")
|
||||
return (False, None, blocked_reason)
|
||||
|
||||
# 4. Chercher lien de téléchargement
|
||||
if cancel_event and cancel_event.is_set():
|
||||
@@ -517,19 +620,40 @@ def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progr
|
||||
]
|
||||
|
||||
direct_link = None
|
||||
# Examine each pattern and validate the candidate link via HEAD/GET to avoid landing pages (/register, /login)
|
||||
for idx, pattern in enumerate(patterns):
|
||||
match = re.search(pattern, html, re.IGNORECASE)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
captured_link = match.group(1)
|
||||
except IndexError:
|
||||
logger.warning(f"1fichier: Pattern {idx} matched but no capture group(1)")
|
||||
continue
|
||||
candidate_entries: list[tuple[int, str]] = []
|
||||
seen_candidates: set[str] = set()
|
||||
|
||||
# Resolve relative links
|
||||
candidate = captured_link if captured_link.startswith(('http://', 'https://')) else urljoin(str(r.url), captured_link)
|
||||
for anchor_match in re.finditer(r'<a[^>]+href=[\"\']([^\"\']+)[\"\'][^>]*>(.*?)</a>', html, re.IGNORECASE | re.DOTALL):
|
||||
href = html_module.unescape(anchor_match.group(1).strip())
|
||||
anchor_text = re.sub(r'<[^>]+>', ' ', anchor_match.group(2))
|
||||
normalized_anchor_text = _normalize_1fichier_text(anchor_text)
|
||||
if not href or not normalized_anchor_text:
|
||||
continue
|
||||
if not any(token in normalized_anchor_text for token in ('download', 'telecharg', 'tlcharg', 'click', 'cliquer')):
|
||||
continue
|
||||
candidate = href if href.startswith(('http://', 'https://')) else urljoin(page_url, href)
|
||||
if candidate in seen_candidates:
|
||||
continue
|
||||
seen_candidates.add(candidate)
|
||||
candidate_entries.append((0, candidate))
|
||||
|
||||
for idx, pattern in enumerate(patterns):
|
||||
for match in re.finditer(pattern, html, re.IGNORECASE):
|
||||
try:
|
||||
captured_link = html_module.unescape(match.group(1).strip())
|
||||
except (IndexError, AttributeError):
|
||||
logger.warning(f"1fichier: Pattern {idx} matched but no usable capture group(1)")
|
||||
continue
|
||||
if not captured_link:
|
||||
continue
|
||||
candidate = captured_link if captured_link.startswith(('http://', 'https://')) else urljoin(page_url, captured_link)
|
||||
if candidate in seen_candidates:
|
||||
continue
|
||||
seen_candidates.add(candidate)
|
||||
candidate_entries.append((idx, candidate))
|
||||
|
||||
# Examine each pattern and validate the candidate link via HEAD/GET to avoid landing pages (/register, /login)
|
||||
for idx, candidate in candidate_entries:
|
||||
logger.debug(f"1fichier: Pattern {idx} matched, candidate link: {candidate}")
|
||||
|
||||
# Quick heuristic: skip known non-download endpoints
|
||||
@@ -573,6 +697,10 @@ def download_1fichier_free_mode(url, dest_dir, session, log_callback=None, progr
|
||||
continue
|
||||
|
||||
if not direct_link:
|
||||
blocked_reason = _extract_1fichier_free_mode_block_reason(html)
|
||||
if blocked_reason:
|
||||
logger.warning(f"1fichier: no direct link because free mode is blocked: {blocked_reason}")
|
||||
return (False, None, blocked_reason)
|
||||
logger.error(f"1fichier: No valid download link found. HTML preview (first 700 chars): {html[:700]}")
|
||||
return (False, None, "Lien de téléchargement introuvable")
|
||||
|
||||
@@ -740,6 +868,14 @@ def _extract_changelog_section(raw_text):
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
def _is_version_heading(heading_text):
|
||||
normalized = str(heading_text or "").strip()
|
||||
return re.match(
|
||||
r"^(?:release\s+)?(?:v(?:ersion)?\s*)?\d+(?:\.\d+)+(?:\s*[(:-].*)?$",
|
||||
normalized,
|
||||
re.IGNORECASE,
|
||||
) is not None
|
||||
|
||||
lines = text.split("\n")
|
||||
heading_re = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
changelog_start = None
|
||||
@@ -763,7 +899,7 @@ def _extract_changelog_section(raw_text):
|
||||
if stripped == "---":
|
||||
break
|
||||
match = heading_re.match(stripped)
|
||||
if match and len(match.group(1)) <= changelog_level:
|
||||
if match and len(match.group(1)) <= changelog_level and not _is_version_heading(match.group(2)):
|
||||
break
|
||||
extracted.append(line)
|
||||
|
||||
@@ -2775,7 +2911,12 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
|
||||
try:
|
||||
# Créer une session requests pour le mode gratuit
|
||||
free_session = requests.Session()
|
||||
free_session.headers.update({'User-Agent': 'Mozilla/5.0'})
|
||||
free_session.headers.update(
|
||||
_build_browser_download_headers(
|
||||
referer=link,
|
||||
accept='text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
)
|
||||
)
|
||||
|
||||
# Callbacks pour le mode gratuit
|
||||
def log_cb(msg):
|
||||
@@ -2862,7 +3003,10 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
|
||||
else:
|
||||
logger.error(f"Échec téléchargement gratuit: {error_msg}")
|
||||
result[0] = False
|
||||
result[1] = f"Error Downloading with free mode: {error_msg}"
|
||||
if isinstance(error_msg, str) and error_msg.startswith("1fichier:"):
|
||||
result[1] = error_msg
|
||||
else:
|
||||
result[1] = f"Error Downloading with free mode: {error_msg}"
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
||||
from email.utils import formatdate, parsedate_to_datetime
|
||||
import config
|
||||
from history import load_history, save_history
|
||||
from utils import load_sources, load_games, extract_data, get_clean_display_name
|
||||
from utils import load_sources, load_games, extract_data, get_clean_display_name, parse_torrent_download_url
|
||||
from network import download_rom, download_from_1fichier
|
||||
from pathlib import Path
|
||||
from rgsx_settings import get_language
|
||||
@@ -1167,6 +1167,14 @@ class RGSXHandler(BaseHTTPRequestHandler):
|
||||
'error': torrent_message
|
||||
}, status=400)
|
||||
return
|
||||
|
||||
if parse_torrent_download_url(game_url) is not None:
|
||||
torrent_message = TRANSLATIONS.get('popup_torrent_in_maintenance', 'torrent in maintence')
|
||||
self._send_json({
|
||||
'success': False,
|
||||
'error': torrent_message
|
||||
}, status=400)
|
||||
return
|
||||
|
||||
# Vérifier l'extension et déterminer si extraction nécessaire
|
||||
from utils import check_extension_before_download
|
||||
|
||||
@@ -289,10 +289,10 @@ def _extract_torrent_source(item) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _expand_torrent_source(item, platform_id: str) -> list[tuple[str, None, str | None]]:
|
||||
def _expand_torrent_source(item, platform_id: str) -> list[tuple[str, None, str | None]] | None:
|
||||
source = _extract_torrent_source(item)
|
||||
if not source:
|
||||
return []
|
||||
return None
|
||||
|
||||
source_name, source_url = source
|
||||
try:
|
||||
@@ -1480,7 +1480,7 @@ def load_games(platform_id:str) -> list[Game]:
|
||||
|
||||
def extract_from_dict(d):
|
||||
torrent_rows = _expand_torrent_source(d, platform_id)
|
||||
if torrent_rows:
|
||||
if torrent_rows is not None:
|
||||
normalized.extend(torrent_rows)
|
||||
return
|
||||
name = d.get('game_name') or d.get('name') or d.get('title') or d.get('game')
|
||||
@@ -1493,7 +1493,7 @@ def load_games(platform_id:str) -> list[Game]:
|
||||
for item in data:
|
||||
if isinstance(item, (list, tuple)):
|
||||
torrent_rows = _expand_torrent_source(item, platform_id)
|
||||
if torrent_rows:
|
||||
if torrent_rows is not None:
|
||||
normalized.extend(torrent_rows)
|
||||
continue
|
||||
if len(item) == 0:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2.6.1.5"
|
||||
"version": "2.6.1.7"
|
||||
}
|
||||
Reference in New Issue
Block a user