Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f1ff89d8 | ||
|
|
75d985f64a | ||
|
|
8b65cb43eb | ||
|
|
02a3d821e3 | ||
|
|
0e07bc8c9d | ||
|
|
8472b91cd1 | ||
|
|
ff5406828c | ||
|
|
c121261f3d | ||
|
|
15a97be8e2 | ||
|
|
97624d6750 | ||
|
|
a12554c010 |
@@ -1,3 +1,4 @@
|
||||
<img src="custom_components/rtkey/icon.png" width="128"> </img>
|
||||
# RT Key Integration for Home Assistant
|
||||
|
||||
[](https://www.home-assistant.io/)
|
||||
|
||||
+492
-233
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,13 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
|
||||
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER
|
||||
from . import _LOGGER, DOMAIN, RTKeyCamerasApi
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -24,15 +23,14 @@ async def async_setup_entry(
|
||||
for camera_info in cameras_info["data"]["items"]
|
||||
]
|
||||
|
||||
# Добавляем архивные камеры событий для домофонов
|
||||
if cameras_api.event_archive_enabled:
|
||||
if cameras_api.archive_copies > 0:
|
||||
intercoms_info = await cameras_api.get_intercoms_info()
|
||||
for intercom_info in intercoms_info.get("data", {}).get("devices", []):
|
||||
entities.append(
|
||||
RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info)
|
||||
)
|
||||
else:
|
||||
_LOGGER.info("Архивные видео событий отключены")
|
||||
_LOGGER.info("Архивные видео событий отключены (archive_copies=0)")
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -64,39 +62,6 @@ class RTKeyCamera(Camera):
|
||||
|
||||
self._attr_unique_id = f"camera_{self.camera_id}"
|
||||
self._attr_name = self.device_name
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
self._unsub_stream_refresh: Any = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self._unsub_stream_refresh = async_track_time_interval(
|
||||
self.hass,
|
||||
self._stream_refresh,
|
||||
datetime.timedelta(seconds=TOKEN_REFRESH_BUFFER),
|
||||
)
|
||||
_LOGGER.debug(f"Таймер обновления стрима запущен для {self.device_name}")
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
if self._unsub_stream_refresh:
|
||||
self._unsub_stream_refresh()
|
||||
self._unsub_stream_refresh = None
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def _stream_refresh(self, now: datetime.datetime) -> None:
|
||||
try:
|
||||
# Если токены протухли — обновляем их перед получением URL
|
||||
if self.cameras_api._tokens_invalid:
|
||||
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
|
||||
await self.cameras_api.refresh_tokens()
|
||||
|
||||
url = await self.stream_source()
|
||||
if url and self.stream and getattr(self.stream, "source", None) != url:
|
||||
_LOGGER.debug(f"Обновляем URL стрима для {self.device_name}")
|
||||
self.stream.update_source(url)
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Ошибка обновления стрима для {self.camera_id}: {e}")
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
return await self.cameras_api.get_camera_stream_url(self.camera_id)
|
||||
@@ -108,16 +73,14 @@ class RTKeyCamera(Camera):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Камера доступна, если она не в списке недоступных."""
|
||||
return self.cameras_api.is_camera_available(self.camera_id)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Атрибуты с динамической проверкой доступности камеры."""
|
||||
if self.cameras_api.is_camera_available(self.camera_id):
|
||||
status = self._device_status_title
|
||||
else:
|
||||
status = "❌ Нет связи"
|
||||
status = "Нет связи"
|
||||
|
||||
attrs = {"Статус": status}
|
||||
if self._device_model:
|
||||
@@ -145,7 +108,7 @@ class RTKeyCamera(Camera):
|
||||
|
||||
|
||||
class RTKeyEventCamera(Camera):
|
||||
"""Камера для просмотра архивного видео на момент последнего события."""
|
||||
"""Архив события: thumbnail-скриншот + ссылка на видео-файл."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
@@ -172,11 +135,11 @@ class RTKeyEventCamera(Camera):
|
||||
|
||||
self._attr_unique_id = f"event_camera_{self.intercom_id}"
|
||||
self._attr_name = "Архив события"
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
self._event_description = None
|
||||
self._event_time = None
|
||||
self._archive_url = None
|
||||
self._archive_path = None
|
||||
self._archive_error = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
@@ -199,20 +162,30 @@ class RTKeyEventCamera(Camera):
|
||||
if not event_info:
|
||||
return
|
||||
|
||||
archive_url = event_info.get("archive_url")
|
||||
if archive_url:
|
||||
self._archive_url = archive_url
|
||||
archive_path = event_info.get("archive_path")
|
||||
archive_error = event_info.get("archive_error")
|
||||
|
||||
if archive_path:
|
||||
self._archive_path = archive_path
|
||||
self._archive_error = None
|
||||
self._event_description = event_info.get("description")
|
||||
self._event_time = event_info.get("local_time")
|
||||
|
||||
if self.stream:
|
||||
_LOGGER.debug(f"Обновляем URL архива для {self.device_name}")
|
||||
self.stream.update_source(archive_url)
|
||||
|
||||
self.async_write_ha_state()
|
||||
elif archive_error:
|
||||
self._archive_error = archive_error
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
return self._archive_url
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Скриншот камеры как thumbnail для карточки."""
|
||||
if self.camera_id:
|
||||
return await self.cameras_api.get_camera_image(self.camera_id)
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.camera_id is not None and self.cameras_api.is_camera_available(self.camera_id)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -221,8 +194,10 @@ class RTKeyEventCamera(Camera):
|
||||
attrs["Событие"] = self._event_description
|
||||
if self._event_time:
|
||||
attrs["Время"] = self._event_time
|
||||
if self._archive_url:
|
||||
attrs["URL архива"] = self._archive_url
|
||||
if self._archive_path:
|
||||
attrs["Путь к архиву"] = self._archive_path
|
||||
if self._archive_error:
|
||||
attrs["Ошибка скачивания"] = self._archive_error
|
||||
return attrs
|
||||
|
||||
@property
|
||||
|
||||
@@ -5,7 +5,6 @@ from homeassistant.config_entries import (
|
||||
OptionsFlowWithConfigEntry,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
|
||||
from . import (
|
||||
CONF_NAME,
|
||||
CONF_TOKEN,
|
||||
@@ -16,11 +15,12 @@ from . import (
|
||||
CONF_EVENTS_REFRESH_INTERVAL,
|
||||
CONF_EVENT_TIME_LAG,
|
||||
CONF_PRE_EVENT_SECONDS,
|
||||
CONF_EVENT_SCREENSHOT_ENABLED,
|
||||
CONF_EVENT_SCREENSHOT_QUALITY,
|
||||
CONF_EVENT_ARCHIVE_ENABLED,
|
||||
CONF_ARCHIVE_DURATION,
|
||||
CONF_EVENT_SENSOR_ENABLED,
|
||||
CONF_ARCHIVE_PATH,
|
||||
CONF_ARCHIVE_COPIES,
|
||||
CONF_ARCHIVE_DURATION,
|
||||
CONF_SCREENSHOT_COPIES,
|
||||
DATA_SCHEMA,
|
||||
OPTIONS_SCHEMA,
|
||||
DOMAIN,
|
||||
@@ -64,11 +64,10 @@ class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
options=user_input,
|
||||
)
|
||||
|
||||
# ✅ Добавляем add_suggested_values_to_schema для корректной работы переводов
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA),
|
||||
{}, # Пустые значения по умолчанию для первой настройки
|
||||
{},
|
||||
),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.image import ImageEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -8,7 +8,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER
|
||||
|
||||
|
||||
@@ -20,7 +19,6 @@ async def async_setup_entry(
|
||||
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
|
||||
cameras_info = await cameras_api.get_cameras_info()
|
||||
|
||||
# Проверяем, включены ли обычные скриншоты
|
||||
interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60)
|
||||
|
||||
entities = []
|
||||
@@ -34,15 +32,15 @@ async def async_setup_entry(
|
||||
else:
|
||||
_LOGGER.info("Обычные скриншоты отключены (interval=0)")
|
||||
|
||||
# Скриншоты событий
|
||||
if cameras_api.event_screenshot_enabled:
|
||||
# 🆕 Скриншоты событий только если screenshot_copies > 0
|
||||
if cameras_api.screenshot_copies > 0:
|
||||
intercoms_info = await cameras_api.get_intercoms_info()
|
||||
for intercom_info in intercoms_info.get("data", {}).get("devices", []):
|
||||
entities.append(
|
||||
RTKeyEventImage(hass, config_entry, cameras_api, intercom_info)
|
||||
)
|
||||
else:
|
||||
_LOGGER.info("Скриншоты событий отключены")
|
||||
_LOGGER.info("Скриншоты событий отключены (screenshot_copies=0)")
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -97,8 +95,6 @@ class RTKeyCameraImageEntity(ImageEntity):
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def _async_update_image(self, now: datetime | None = None) -> None:
|
||||
"""Продолжаем опрашивать API даже если камера недоступна,
|
||||
чтобы узнать, когда она снова появится."""
|
||||
try:
|
||||
image = await self.cameras_api.get_camera_image(self.camera_id)
|
||||
if image:
|
||||
@@ -114,12 +110,10 @@ class RTKeyCameraImageEntity(ImageEntity):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Сущность доступна, если камера не в списке недоступных."""
|
||||
return self.cameras_api.is_camera_available(self.camera_id)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Атрибуты с динамической проверкой доступности камеры."""
|
||||
if self.cameras_api.is_camera_available(self.camera_id):
|
||||
status = self._device_status_title
|
||||
else:
|
||||
@@ -147,7 +141,7 @@ class RTKeyCameraImageEntity(ImageEntity):
|
||||
|
||||
|
||||
class RTKeyEventImage(ImageEntity):
|
||||
"""Скриншот на момент последнего события домофона."""
|
||||
"""Скриншот на момент последнего события домофона (локальный файл)."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
@@ -176,7 +170,8 @@ class RTKeyEventImage(ImageEntity):
|
||||
self._attr_name = "Скриншот события"
|
||||
|
||||
self._attr_image_last_updated = None
|
||||
self._cached_image: bytes | None = None
|
||||
self._screenshot_path = None
|
||||
self._screenshot_error = None
|
||||
self._event_description = None
|
||||
self._event_time = None
|
||||
|
||||
@@ -201,13 +196,15 @@ class RTKeyEventImage(ImageEntity):
|
||||
if not event_info:
|
||||
return
|
||||
|
||||
screenshot_data = event_info.get("screenshot_data")
|
||||
if screenshot_data:
|
||||
self._cached_image = screenshot_data
|
||||
screenshot_path = event_info.get("screenshot_path")
|
||||
screenshot_error = event_info.get("screenshot_error")
|
||||
|
||||
if screenshot_path:
|
||||
self._screenshot_path = screenshot_path
|
||||
self._screenshot_error = None
|
||||
self._event_description = event_info.get("description")
|
||||
self._event_time = event_info.get("local_time")
|
||||
|
||||
# ✅ Используем время события, а не текущее время
|
||||
event_timestamp = event_info.get("timestamp")
|
||||
if event_timestamp:
|
||||
self._attr_image_last_updated = datetime.fromtimestamp(
|
||||
@@ -219,9 +216,25 @@ class RTKeyEventImage(ImageEntity):
|
||||
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(f"Обновлён скриншот события для {self.device_name}")
|
||||
elif screenshot_error:
|
||||
self._screenshot_error = screenshot_error
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
return self._cached_image
|
||||
"""Читаем скриншот с диска."""
|
||||
if self._screenshot_path and Path(self._screenshot_path).exists():
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(
|
||||
Path(self._screenshot_path).read_bytes
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Ошибка чтения скриншота {self._screenshot_path}: {e}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Доступна, если есть локальный файл."""
|
||||
return self._screenshot_path is not None and Path(self._screenshot_path).exists()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -230,6 +243,10 @@ class RTKeyEventImage(ImageEntity):
|
||||
attrs["Событие"] = self._event_description
|
||||
if self._event_time:
|
||||
attrs["Время"] = self._event_time
|
||||
if self._screenshot_path:
|
||||
attrs["Путь к скриншоту"] = self._screenshot_path
|
||||
if self._screenshot_error:
|
||||
attrs["Ошибка скачивания"] = self._screenshot_error
|
||||
return attrs
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"documentation": "https://github.com/sirmrak/RTKey",
|
||||
"issue_tracker": "https://github.com/sirmrak/RTKey/issues",
|
||||
"iot_class": "cloud_polling",
|
||||
"version": "1.0.1",
|
||||
"requirements": ["transliterate", "pyjwt"],
|
||||
"version": "1.2.2",
|
||||
"requirements": [],
|
||||
"config_flow": true,
|
||||
"codeowners": ["@sirmrak"]
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ class RTKeyEventSensor(SensorEntity):
|
||||
"Тип события": self._event_data.get("event_type", ""),
|
||||
}
|
||||
|
||||
archive_url = self._event_data.get("archive_url")
|
||||
if archive_url:
|
||||
attrs["Архив видео"] = archive_url
|
||||
archive_path = self._event_data.get("archive_path")
|
||||
if archive_path:
|
||||
attrs["Архив видео"] = archive_path
|
||||
|
||||
event = self._event_data.get("event", {})
|
||||
rfid = event.get("rfid")
|
||||
|
||||
@@ -14,15 +14,20 @@
|
||||
"events_refresh_interval": "Event polling interval (sec)",
|
||||
"event_time_lag": "Camera time lag (sec)",
|
||||
"pre_event_seconds": "Seconds before event to show",
|
||||
"event_screenshot_enabled": "Create event screenshot entity",
|
||||
"event_screenshot_quality": "Event screenshot quality",
|
||||
"event_archive_enabled": "Create event archive camera",
|
||||
"archive_duration": "Archive duration (sec)",
|
||||
"event_sensor_enabled": "Create event sensor entity"
|
||||
"event_sensor_enabled": "Create event sensor entity",
|
||||
"archive_path": "Archive storage path",
|
||||
"archive_copies": "Number of video copies (0 = disabled)",
|
||||
"archive_duration": "Archive video duration (sec)",
|
||||
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "Get token via browser developer tools (F12) on key.rt.ru",
|
||||
"camera_image_refresh_interval": "0 — disable periodic screenshots"
|
||||
"camera_image_refresh_interval": "0 — disable periodic screenshots",
|
||||
"archive_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
|
||||
"archive_copies": "Maximum number of saved videos per intercom",
|
||||
"archive_duration": "Duration of archived video fragment per event",
|
||||
"screenshot_copies": "Maximum number of saved screenshots per intercom"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,15 +47,20 @@
|
||||
"events_refresh_interval": "Event polling interval (sec)",
|
||||
"event_time_lag": "Camera time lag (sec)",
|
||||
"pre_event_seconds": "Seconds before event to show",
|
||||
"event_screenshot_enabled": "Create event screenshot entity",
|
||||
"event_screenshot_quality": "Event screenshot quality",
|
||||
"event_archive_enabled": "Create event archive camera",
|
||||
"archive_duration": "Archive duration (sec)",
|
||||
"event_sensor_enabled": "Create event sensor entity"
|
||||
"event_sensor_enabled": "Create event sensor entity",
|
||||
"archive_path": "Archive storage path",
|
||||
"archive_copies": "Number of video copies (0 = disabled)",
|
||||
"archive_duration": "Archive video duration (sec)",
|
||||
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "Get token via browser developer tools (F12) on key.rt.ru",
|
||||
"camera_image_refresh_interval": "0 — disable periodic screenshots"
|
||||
"camera_image_refresh_interval": "0 — disable periodic screenshots",
|
||||
"archive_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
|
||||
"archive_copies": "Maximum number of saved videos per intercom",
|
||||
"archive_duration": "Duration of archived video fragment per event",
|
||||
"screenshot_copies": "Maximum number of saved screenshots per intercom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,20 @@
|
||||
"events_refresh_interval": "Интервал опроса событий (сек)",
|
||||
"event_time_lag": "Задержка времени камеры (сек)",
|
||||
"pre_event_seconds": "Секунд до события для показа",
|
||||
"event_screenshot_enabled": "Создавать сущность скриншота события",
|
||||
"event_screenshot_quality": "Качество скриншота события",
|
||||
"event_archive_enabled": "Создавать камеру архива событий",
|
||||
"archive_duration": "Длительность архива (сек)",
|
||||
"event_sensor_enabled": "Создавать сенсор последнего события"
|
||||
"event_sensor_enabled": "Создавать сенсор последнего события",
|
||||
"archive_path": "Путь для сохранения архивов",
|
||||
"archive_copies": "Количество копий видео (0 = отключено)",
|
||||
"archive_duration": "Длительность видео архива (сек)",
|
||||
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
|
||||
"camera_image_refresh_interval": "0 — отключить периодические скриншоты"
|
||||
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
|
||||
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
|
||||
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
||||
"archive_duration": "Длительность фрагмента видео архива на событие",
|
||||
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,15 +47,20 @@
|
||||
"events_refresh_interval": "Интервал опроса событий (сек)",
|
||||
"event_time_lag": "Задержка времени камеры (сек)",
|
||||
"pre_event_seconds": "Секунд до события для показа",
|
||||
"event_screenshot_enabled": "Создавать сущность скриншота события",
|
||||
"event_screenshot_quality": "Качество скриншота события",
|
||||
"event_archive_enabled": "Создавать камеру архива событий",
|
||||
"archive_duration": "Длительность архива (сек)",
|
||||
"event_sensor_enabled": "Создавать сенсор последнего события"
|
||||
"event_sensor_enabled": "Создавать сенсор последнего события",
|
||||
"archive_path": "Путь для сохранения архивов",
|
||||
"archive_copies": "Количество копий видео (0 = отключено)",
|
||||
"archive_duration": "Длительность видео архива (сек)",
|
||||
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
|
||||
"camera_image_refresh_interval": "0 — отключить периодические скриншоты"
|
||||
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
|
||||
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
|
||||
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
||||
"archive_duration": "Длительность фрагмента видео архива на событие",
|
||||
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user