docs: reference integrations + examples
- docs/integrations/frigate.md — полный production-tested guide: Dockerfile, docker-compose, config.yml, troubleshooting (s6+pid, scale_cuda, hwaccel issues), build steps - docs/integrations/cctv-cpp.md — C++ pattern: IFrameSource interface + CuframesSource skeleton + CMake setup + runtime requirements - examples/frigate-compose/ — reference compose stack (cuframes-pub + Frigate) с config.yml stub, .env.example, README - examples/python-consumer/ — ctypes-based skeleton для AI/ML pipeline'ов (до v0.3 native pybind11 bindings) - docs/integration.md — превратился в index-страницу, ссылается на specific guides Reorganization упрощает onboarding: пользователь выбирает guide по типу integration'а (Frigate/C++/Python/FFmpeg) и сразу видит реальный code. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Скопировать в .env (не commit'ить!)
|
||||
# .env должен быть в .gitignore
|
||||
|
||||
# Камеры: пароли admin user'а на Dahua/Hikvision/etc
|
||||
CAM_PARKING_PASS=changeme
|
||||
|
||||
# Frigate API/UI auth password
|
||||
FRIGATE_RTSP_PASSWORD=changeme
|
||||
@@ -0,0 +1,61 @@
|
||||
# examples/frigate-compose
|
||||
|
||||
Reference docker-compose для Frigate + cuframes integration. **НЕ** копировать
|
||||
в production бездумно — это шаблон, адаптируй под свою инфру (IP-адреса камер,
|
||||
пароли, mount paths, network).
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. Build patched Frigate image (single-time setup, ~15 мин):
|
||||
```bash
|
||||
# См. docs/integrations/frigate.md, Шаг 1 — там полный Dockerfile.
|
||||
docker build -t local/frigate-cuframes:latest -f Dockerfile.frigate .
|
||||
```
|
||||
|
||||
2. Pull cuframes publisher image:
|
||||
```bash
|
||||
docker pull git.goldix.org/gx/cuframes:0.1
|
||||
# либо собрать local: docker build -t local/cuframes:0.1 -f docker/Dockerfile.runtime ../..
|
||||
```
|
||||
|
||||
3. Скопировать .env:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
$EDITOR .env # подставь свои camera passwords
|
||||
```
|
||||
|
||||
4. Адаптировать `docker-compose.yml`:
|
||||
- `parking-cam-ip` → реальный IP камеры
|
||||
- `--key cam-parking` → имя по вкусу (должно matche'ить config.yml `cuframes://<key>`)
|
||||
- `cam-parking` в Frigate config → так же matched
|
||||
|
||||
5. Адаптировать `config/config.yml`:
|
||||
- детектор (cpu / onnx / tensorrt)
|
||||
- пути к media
|
||||
- дополнительные камеры если нужно
|
||||
|
||||
6. Run:
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker logs -f frigate
|
||||
# UI: http://localhost:5000 (internal) либо https://localhost:8971 (auth)
|
||||
```
|
||||
|
||||
## Что демонстрирует
|
||||
|
||||
- Один publisher (`cuframes-pub-parking`) делает 1× NVDEC на parking-камеру
|
||||
- Frigate подключается к publisher через `ipc:container:` + `cuframes://` URL
|
||||
- Frigate **не** делает свой NVDEC для detect-path — берёт готовые NV12 frames
|
||||
|
||||
## Что НЕ демонстрирует
|
||||
|
||||
- Record path — Frigate всё ещё открывает второй RTSP к камере (для архива
|
||||
`-c:v copy` mux). v0.2 cuframes решит через encoded packet sharing
|
||||
(см. [issue #2](https://git.goldix.org/gx/cuframes/issues/2))
|
||||
- Multi-camera setup — добавь больше publisher'ов и camera-blocks в config.yml
|
||||
- HA/MQTT интеграция — добавь свой mqtt block
|
||||
|
||||
## См. также
|
||||
|
||||
- [docs/integrations/frigate.md](../../docs/integrations/frigate.md) — полный walkthrough
|
||||
- [docs/integration.md](../../docs/integration.md) — общая интеграция
|
||||
@@ -0,0 +1,49 @@
|
||||
# Minimal Frigate config с cuframes integration.
|
||||
# Полный guide: docs/integrations/frigate.md
|
||||
|
||||
mqtt:
|
||||
enabled: false
|
||||
|
||||
detectors:
|
||||
# Замени на свой detector (tensorrt / onnx / cpu). Здесь — placeholder.
|
||||
cpu:
|
||||
type: cpu
|
||||
|
||||
# CRITICAL: hwaccel cuda отключён — наш patched ffmpeg без --enable-cuda-llvm
|
||||
# (не работает на glibc < 2.38 что у Debian 12, на котором Frigate runtime).
|
||||
# Без cuda-llvm нет scale_cuda filter. Detect-path использует CPU scale, но
|
||||
# decode уже сделан у publisher'а — net выигрыш всё равно.
|
||||
ffmpeg:
|
||||
hwaccel_args: []
|
||||
output_args:
|
||||
record: preset-record-generic-audio-aac
|
||||
|
||||
cameras:
|
||||
parking_overview:
|
||||
enabled: true
|
||||
friendly_name: Парковка
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# main (full-res) — только запись в архив через прямой RTSP (`-c:v copy`, no decode у Frigate)
|
||||
# После cuframes v0.2 этот path тоже может через cuframes_packets:// (encoded share)
|
||||
- path: rtsp://admin:${FRIGATE_RTSP_PASSWORD}@parking-cam-ip:554/cam/realmonitor?channel=1&subtype=0
|
||||
roles: [record]
|
||||
|
||||
# sub-stream → через cuframes (decoded у publisher'а, без второго NVDEC)
|
||||
- path: cuframes://cam-parking
|
||||
input_args: -f cuframes
|
||||
roles: [detect]
|
||||
detect:
|
||||
width: 640
|
||||
height: 480
|
||||
fps: 5
|
||||
|
||||
record:
|
||||
enabled: true
|
||||
retain:
|
||||
days: 7
|
||||
|
||||
snapshots:
|
||||
enabled: true
|
||||
retain:
|
||||
default: 7
|
||||
@@ -0,0 +1,73 @@
|
||||
# Reference docker-compose для Frigate + cuframes integration.
|
||||
# Полный guide: docs/integrations/frigate.md
|
||||
#
|
||||
# Что нужно подготовить заранее:
|
||||
# 1. Build local image local/frigate-cuframes:latest по Dockerfile.frigate
|
||||
# (см. docs/integrations/frigate.md, Шаг 1)
|
||||
# 2. Pull cuframes runtime image:
|
||||
# docker pull git.goldix.org/gx/cuframes:0.1 # либо собрать local
|
||||
# 3. Скопировать config/config.yml (placeholder в config/ рядом)
|
||||
# 4. .env с CAM_PARKING_PASS=... и FRIGATE_RTSP_PASSWORD=...
|
||||
#
|
||||
# Запуск:
|
||||
# docker compose up -d
|
||||
# # UI: http://host:5000 (internal, без auth) либо https://host:8971 (with auth)
|
||||
|
||||
services:
|
||||
# 1× publisher на камеру — single source of RTSP + NVDEC
|
||||
cuframes-pub-parking:
|
||||
image: git.goldix.org/gx/cuframes:0.1
|
||||
container_name: cuframes-pub-parking
|
||||
restart: unless-stopped
|
||||
runtime: nvidia
|
||||
ipc: shareable
|
||||
shm_size: 256m
|
||||
environment:
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,video,utility
|
||||
volumes:
|
||||
- cuframes_sock:/run/cuframes
|
||||
command:
|
||||
- /usr/local/bin/cuframes-rtsp-source
|
||||
- --rtsp
|
||||
# Используем sub-stream для detect-path (lighter resolution, тот же camera load)
|
||||
- "rtsp://admin:${CAM_PARKING_PASS}@parking-cam-ip:554/cam/realmonitor?channel=1&subtype=1"
|
||||
- --key
|
||||
- cam-parking
|
||||
- --ring
|
||||
- "6"
|
||||
- --verbose
|
||||
|
||||
frigate:
|
||||
image: local/frigate-cuframes:latest # см. docs/integrations/frigate.md Шаг 1
|
||||
container_name: frigate
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
cuframes-pub-parking:
|
||||
condition: service_started
|
||||
runtime: nvidia
|
||||
privileged: true
|
||||
shm_size: 512m
|
||||
# WARN: только ipc share — pid НЕ shared (Frigate's s6-overlay требует PID 1).
|
||||
# Frigate подсоединяется к first CUDA context publisher'а в shared /dev/shm.
|
||||
ipc: "container:cuframes-pub-parking"
|
||||
environment:
|
||||
FRIGATE_RTSP_PASSWORD: "${FRIGATE_RTSP_PASSWORD}"
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,video,utility
|
||||
ports:
|
||||
- "5000:5000" # UI без auth (internal, не expose external!)
|
||||
- "8971:8971" # UI с HTTPS + auth
|
||||
- "8554:8554" # RTSP restream (go2rtc)
|
||||
- "8555:8555/tcp"
|
||||
- "8555:8555/udp"
|
||||
volumes:
|
||||
- cuframes_sock:/run/cuframes:ro
|
||||
- ./config/config.yml:/config/config.yml:ro
|
||||
- ./media:/media/frigate
|
||||
- type: tmpfs
|
||||
target: /tmp/cache
|
||||
tmpfs: { size: 1000000000 }
|
||||
|
||||
volumes:
|
||||
cuframes_sock:
|
||||
@@ -0,0 +1,78 @@
|
||||
# examples/python-consumer
|
||||
|
||||
Reference Python consumer для cuframes через `ctypes` wrapper.
|
||||
|
||||
## Use case
|
||||
|
||||
AI/ML pipeline (PyTorch / ONNX / TensorRT) которому нужны декодированные кадры
|
||||
с камер. Без cuframes — каждый Python скрипт открывает RTSP + decode сам.
|
||||
С cuframes — подписывается на готовые NV12 frames от publisher'а.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
# Publisher должен быть запущен (см. tools/cuframes-rtsp-source или Docker image)
|
||||
cuframes-rtsp-source --rtsp rtsp://admin:pw@cam-ip:554/... --key cam-parking &
|
||||
|
||||
# Consumer (same host, либо same docker namespace — см. требования ниже)
|
||||
python3 cuframes_consumer.py --key cam-parking --max-frames 100
|
||||
```
|
||||
|
||||
Ожидаемый output:
|
||||
```
|
||||
[consumer] connected to 'cam-parking'
|
||||
[consumer] first frame: 640x480 NV12, pitch_y=640, pitch_uv=640, cuda_ptr=0x...
|
||||
[consumer] received=25 seq=42 pts_ms=...
|
||||
...
|
||||
=== RESULT ===
|
||||
received: 100 / 100
|
||||
elapsed: 3.96s
|
||||
avg_fps: 25.03
|
||||
```
|
||||
|
||||
## Что этот пример НЕ делает
|
||||
|
||||
- **НЕ копирует** GPU NV12 frame на host — `cuda_ptr` это raw CUDA device pointer.
|
||||
Для реальной работы нужно:
|
||||
- `pycuda` / `cupy` / `cuda-python` библиотека для CUDA memcpy
|
||||
- либо передать `cuda_ptr` напрямую в GPU-aware ML framework (PyTorch's
|
||||
`torch.cuda.IntTensor.from_dlpack` etc.)
|
||||
|
||||
- **НЕ конвертирует** NV12 → RGB. Используй `cv2.cvtColor(nv12, cv2.COLOR_YUV2RGB_NV12)`
|
||||
на host или GPU-side conversion.
|
||||
|
||||
- **НЕ обрабатывает** inference — это skeleton, в твоём pipeline replace
|
||||
comment-block `### ВАШ ML PIPELINE ЗДЕСЬ ###` с актуальным кодом.
|
||||
|
||||
## Требования
|
||||
|
||||
| | Значение |
|
||||
|---|---|
|
||||
| Python | 3.8+ |
|
||||
| `libcuframes.so.0` | в `LD_LIBRARY_PATH` (либо `/usr/local/lib`) |
|
||||
| Publisher running | да, с matching `--key` |
|
||||
| Same IPC namespace | да (host либо `ipc:container:<publisher>` в docker) |
|
||||
| Same PID namespace | да (host либо `pid:container:<publisher>` в docker) |
|
||||
| NVIDIA GPU + driver | для access `cuda_ptr` (read-only frame от publisher'а) |
|
||||
|
||||
## Docker-style
|
||||
|
||||
```yaml
|
||||
# В compose рядом с publisher service
|
||||
ai-pipeline:
|
||||
image: your-ai-image:cuda
|
||||
runtime: nvidia
|
||||
ipc: "container:cuframes-pub-parking"
|
||||
pid: "container:cuframes-pub-parking"
|
||||
volumes:
|
||||
- cuframes_sock:/run/cuframes:ro
|
||||
environment:
|
||||
LD_LIBRARY_PATH: /usr/local/lib
|
||||
command: python3 /app/cuframes_consumer.py --key cam-parking --max-frames 1000000
|
||||
```
|
||||
|
||||
## v0.3 → first-class pybind11 bindings
|
||||
|
||||
Текущий ctypes pattern будет заменён на native pybind11 bindings в v0.3 cuframes
|
||||
([ROADMAP.md](../../ROADMAP.md)). Тогда API будет более pythonic + zero-copy через
|
||||
`__cuda_array_interface__` / `dlpack`.
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reference Python consumer для cuframes (через ctypes wrapper).
|
||||
|
||||
До v0.3 (когда появятся первоклассные pybind11 bindings) — это minimal
|
||||
working pattern для AI/ML скриптов которые хотят подписаться на cuframes IPC.
|
||||
|
||||
Pattern:
|
||||
1. subscribe to cuframes (open libcuframes.so via ctypes)
|
||||
2. в цикле: получить next() frame
|
||||
3. cudaMemcpy → host (через pycuda либо отдельной CUDA-Python библиотекой)
|
||||
4. передать в свой ML pipeline (ONNX/TensorRT/PyTorch)
|
||||
5. release frame обратно publisher'у
|
||||
|
||||
Limitations:
|
||||
- Этот skeleton НЕ делает actual CUDA copy (нужна pycuda / cupy / cuda-python)
|
||||
- Только sync API
|
||||
- Только NV12 (v0.1)
|
||||
|
||||
Запуск:
|
||||
python3 cuframes_consumer.py --key cam-parking --max-frames 100
|
||||
|
||||
Требования (на target host):
|
||||
- libcuframes.so в LD_LIBRARY_PATH (либо apt install / docker)
|
||||
- publisher запущен (cuframes-rtsp-source --key cam-parking ...)
|
||||
- same IPC + PID namespace что publisher (если в docker — ipc:container: + pid:container:)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import sys
|
||||
import time
|
||||
from ctypes import c_int, c_int32, c_int64, c_uint64, c_uint32, c_char_p, c_void_p, c_size_t, POINTER, Structure
|
||||
|
||||
|
||||
# ─── C API bindings ─────────────────────────────────────────────────────
|
||||
|
||||
# Error codes
|
||||
CUFRAMES_OK = 0
|
||||
CUFRAMES_ERR_TIMEOUT = -7
|
||||
CUFRAMES_ERR_WOULD_BLOCK = -11
|
||||
CUFRAMES_ERR_DISCONNECTED = -9
|
||||
|
||||
# Modes
|
||||
CUFRAMES_MODE_NEWEST_ONLY = 0
|
||||
CUFRAMES_MODE_STRICT_ORDER = 1
|
||||
|
||||
# Pixel format
|
||||
CUFRAMES_FORMAT_NV12 = 0
|
||||
|
||||
|
||||
class SubscriberConfig(Structure):
|
||||
"""Соответствует C struct cuframes_subscriber_config."""
|
||||
_fields_ = [
|
||||
("key", c_char_p),
|
||||
("consumer_name", c_char_p),
|
||||
("mode", c_int),
|
||||
("cuda_device", c_int32),
|
||||
("connect_timeout_ms", c_int32),
|
||||
("_reserved", c_uint64 * 4),
|
||||
]
|
||||
|
||||
|
||||
def _load_libcuframes():
|
||||
"""Загрузить libcuframes.so + bind ctypes signatures."""
|
||||
try:
|
||||
lib = ctypes.CDLL("libcuframes.so.0")
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"Cannot load libcuframes.so.0: {e}\n")
|
||||
sys.stderr.write("Установи libcuframes (см. cuframes README) и убедись что .so в LD_LIBRARY_PATH.\n")
|
||||
sys.exit(1)
|
||||
|
||||
# cuframes_strerror
|
||||
lib.cuframes_strerror.argtypes = [c_int]
|
||||
lib.cuframes_strerror.restype = c_char_p
|
||||
|
||||
# cuframes_subscriber_create
|
||||
lib.cuframes_subscriber_create.argtypes = [POINTER(SubscriberConfig), POINTER(c_void_p)]
|
||||
lib.cuframes_subscriber_create.restype = c_int
|
||||
|
||||
# cuframes_subscriber_next (consumer_stream=NULL — sync API, default stream)
|
||||
lib.cuframes_subscriber_next.argtypes = [c_void_p, c_void_p, POINTER(c_void_p), c_int32]
|
||||
lib.cuframes_subscriber_next.restype = c_int
|
||||
|
||||
# cuframes_subscriber_release
|
||||
lib.cuframes_subscriber_release.argtypes = [c_void_p, c_void_p]
|
||||
lib.cuframes_subscriber_release.restype = c_int
|
||||
|
||||
# cuframes_subscriber_destroy
|
||||
lib.cuframes_subscriber_destroy.argtypes = [c_void_p]
|
||||
lib.cuframes_subscriber_destroy.restype = c_int
|
||||
|
||||
# cuframes_frame_* accessors
|
||||
lib.cuframes_frame_cuda_ptr.argtypes = [c_void_p]
|
||||
lib.cuframes_frame_cuda_ptr.restype = c_void_p
|
||||
|
||||
lib.cuframes_frame_size.argtypes = [c_void_p, POINTER(c_int32), POINTER(c_int32)]
|
||||
lib.cuframes_frame_size.restype = None
|
||||
|
||||
lib.cuframes_frame_pitch_y.argtypes = [c_void_p]
|
||||
lib.cuframes_frame_pitch_y.restype = c_int32
|
||||
|
||||
lib.cuframes_frame_pitch_uv.argtypes = [c_void_p]
|
||||
lib.cuframes_frame_pitch_uv.restype = c_int32
|
||||
|
||||
lib.cuframes_frame_seq.argtypes = [c_void_p]
|
||||
lib.cuframes_frame_seq.restype = c_uint64
|
||||
|
||||
lib.cuframes_frame_pts_ns.argtypes = [c_void_p]
|
||||
lib.cuframes_frame_pts_ns.restype = c_int64
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
# ─── Main consumer loop ────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Reference cuframes Python consumer")
|
||||
ap.add_argument("--key", required=True, help="publisher key (e.g. cam-parking)")
|
||||
ap.add_argument("--max-frames", type=int, default=100, help="N frames to receive (default 100)")
|
||||
ap.add_argument("--cuda-device", type=int, default=0)
|
||||
ap.add_argument("--timeout-ms", type=int, default=1000, help="per-frame timeout")
|
||||
args = ap.parse_args()
|
||||
|
||||
lib = _load_libcuframes()
|
||||
|
||||
# Configure subscriber
|
||||
cfg = SubscriberConfig()
|
||||
cfg.key = args.key.encode("utf-8")
|
||||
cfg.consumer_name = None # auto-generated
|
||||
cfg.mode = CUFRAMES_MODE_NEWEST_ONLY
|
||||
cfg.cuda_device = args.cuda_device
|
||||
cfg.connect_timeout_ms = 5000
|
||||
|
||||
sub_handle = c_void_p()
|
||||
rc = lib.cuframes_subscriber_create(ctypes.byref(cfg), ctypes.byref(sub_handle))
|
||||
if rc != CUFRAMES_OK:
|
||||
sys.stderr.write(f"subscribe failed: {lib.cuframes_strerror(rc).decode()}\n")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[consumer] connected to '{args.key}'")
|
||||
|
||||
received = 0
|
||||
first_pts = None
|
||||
start_wall = None
|
||||
|
||||
try:
|
||||
while received < args.max_frames:
|
||||
frame_handle = c_void_p()
|
||||
rc = lib.cuframes_subscriber_next(sub_handle, None, ctypes.byref(frame_handle),
|
||||
args.timeout_ms)
|
||||
|
||||
if rc == CUFRAMES_ERR_TIMEOUT or rc == CUFRAMES_ERR_WOULD_BLOCK:
|
||||
continue
|
||||
if rc == CUFRAMES_ERR_DISCONNECTED:
|
||||
print(f"[consumer] publisher disconnected — exit")
|
||||
break
|
||||
if rc != CUFRAMES_OK or not frame_handle.value:
|
||||
sys.stderr.write(f"next failed: {lib.cuframes_strerror(rc).decode()}\n")
|
||||
break
|
||||
|
||||
# Frame metadata
|
||||
w, h = c_int32(0), c_int32(0)
|
||||
lib.cuframes_frame_size(frame_handle, ctypes.byref(w), ctypes.byref(h))
|
||||
pitch_y = lib.cuframes_frame_pitch_y(frame_handle)
|
||||
pitch_uv = lib.cuframes_frame_pitch_uv(frame_handle)
|
||||
cuda_ptr = lib.cuframes_frame_cuda_ptr(frame_handle)
|
||||
seq = lib.cuframes_frame_seq(frame_handle)
|
||||
pts_ns = lib.cuframes_frame_pts_ns(frame_handle)
|
||||
|
||||
if first_pts is None:
|
||||
first_pts = pts_ns
|
||||
start_wall = time.monotonic()
|
||||
print(f"[consumer] first frame: {w.value}x{h.value} NV12, "
|
||||
f"pitch_y={pitch_y}, pitch_uv={pitch_uv}, cuda_ptr=0x{cuda_ptr:x}")
|
||||
|
||||
# ─── ВАШ ML PIPELINE ЗДЕСЬ ────────────────────────────
|
||||
# 1. cudaMemcpy NV12 frame → host (или используй pycuda / cupy для in-GPU pipeline)
|
||||
# 2. NV12 → RGB conversion (CPU либо GPU)
|
||||
# 3. inference: model(frame) → results
|
||||
# 4. publish results (mqtt / API / etc)
|
||||
#
|
||||
# В этом skeleton — просто counter.
|
||||
received += 1
|
||||
if received % 25 == 0:
|
||||
print(f"[consumer] received={received} seq={seq} pts_ms={pts_ns // 1_000_000}")
|
||||
|
||||
# CRITICAL: release frame ОБЯЗАТЕЛЬНО — иначе publisher застрянет
|
||||
# (или drop new frames при ring overflow в STRICT_ORDER mode).
|
||||
lib.cuframes_subscriber_release(sub_handle, frame_handle)
|
||||
|
||||
finally:
|
||||
lib.cuframes_subscriber_destroy(sub_handle)
|
||||
|
||||
if received > 1 and start_wall:
|
||||
elapsed = time.monotonic() - start_wall
|
||||
fps = (received - 1) / elapsed if elapsed > 0 else 0
|
||||
print(f"\n=== RESULT ===")
|
||||
print(f"received: {received} / {args.max_frames}")
|
||||
print(f"elapsed: {elapsed:.2f}s")
|
||||
print(f"avg_fps: {fps:.2f}")
|
||||
sys.exit(0 if received >= args.max_frames else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user