fix(studio): 回放视图里选了不是记录的视频时,明确说明原因并给出可行路径
- What: `_load_replay_video()` 先用与 `list_recordings()` 相同的判据确认"这是一条记录"(视频同目录 必须有 `forces.csv`);不是记录时不再把该目录当成记录打开,而是给出原因(状态栏 + 2D 面板常驻文案)、 在 rail 上保留该视频路径,并把「从视频重算力值」置为可用;`_can_recompute()` 统一"重算有没有东西可读" (记录或已选视频),`_sync_menu_checks`/`_refresh_replay_state`/`_on_recompute_toggled` 共用它; 回放按钮在"已选视频但不是记录"时给出对应 tooltip;`_start_playback()` 遇到零长度时间轴时改为明确提示, 不再静默取消勾选。 - Why: 用户报告"历史回放面板,选择视频后按回放键没有反应"。复现确认:`RecordingReader` 容忍空目录, 所以随便一段视频所在目录都会被"成功"打开成一条空记录,`duration_s()==0`,按回放→`_start_playback` 直接静默 return——按钮点了像没点。一条记录的定义(录像 + forces.csv + session.json)此前只写在 `list_recordings` 里,回放入口没有用同一个判据。 - Verification: 复现脚本三种状态(未选/裸视频/记录内视频)行为符合预期;新增三项 UI 用例 (裸视频被拒且说明原因并能重算、记录内视频可打开且能播、缺视频段的记录给出提示); apps/studio 全量通过;ui_check 576 项 0 失败(新增 6 项覆盖同一行为);locale_check 0 缺失。 - Not in this change: 让回放直接播放"非记录"的视频(那需要一条没有力值的纯视频通道;现在用「从视频重算」 覆盖这个需求);`RecordingReader` 对空目录的容忍行为(它要允许正在写入、尚未写 session.json 的记录)。
This commit is contained in:
@@ -102,6 +102,7 @@
|
|||||||
"playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses",
|
"playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses",
|
||||||
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
||||||
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
||||||
|
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"units": "Units",
|
"units": "Units",
|
||||||
"units_newton": "Newton",
|
"units_newton": "Newton",
|
||||||
@@ -205,6 +206,9 @@
|
|||||||
"video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
"video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
||||||
"no_video": "This recording has no usable video segment",
|
"no_video": "This recording has no usable video segment",
|
||||||
"open_failed": "Could not open that recording: {error}",
|
"open_failed": "Could not open that recording: {error}",
|
||||||
|
"not_a_recording": "“{name}” is not a recording: its folder has no forces.csv (a recording is the video plus forces.csv and session.json). Replay needs a recording; to just watch this video, use “Recompute forces from the video”.",
|
||||||
|
"video_only_panel": "This is not a recording (no forces.csv beside it)\nTo watch this video, use\nView ▸ Recompute forces from the video",
|
||||||
|
"empty_recording": "This recording has no playable picture (no raw_*.mp4 segment), so it cannot be replayed",
|
||||||
"none_found": "No recording found under outputs/recordings",
|
"none_found": "No recording found under outputs/recordings",
|
||||||
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
|
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
|
||||||
"frame_unreadable": "This frame could not be decoded from the video",
|
"frame_unreadable": "This frame could not be decoded from the video",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
||||||
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||||
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
||||||
|
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"units": "单位",
|
"units": "单位",
|
||||||
"units_newton": "牛顿",
|
"units_newton": "牛顿",
|
||||||
@@ -205,6 +206,9 @@
|
|||||||
"video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
"video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
||||||
"no_video": "这段录制没有可用的视频分段",
|
"no_video": "这段录制没有可用的视频分段",
|
||||||
"open_failed": "无法打开该录制: {error}",
|
"open_failed": "无法打开该录制: {error}",
|
||||||
|
"not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请用「从视频重算力值」。",
|
||||||
|
"video_only_panel": "这不是一条记录(同目录缺少 forces.csv)\n只想看这段视频请用\n视图 ▸ 从视频重算力值",
|
||||||
|
"empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放",
|
||||||
"none_found": "outputs/recordings 下没有找到录制",
|
"none_found": "outputs/recordings 下没有找到录制",
|
||||||
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
||||||
"frame_unreadable": "该帧无法从视频中解码",
|
"frame_unreadable": "该帧无法从视频中解码",
|
||||||
|
|||||||
@@ -901,15 +901,46 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._load_replay_video(Path(chosen))
|
self._load_replay_video(Path(chosen))
|
||||||
|
|
||||||
def _load_replay_video(self, video: Path) -> bool:
|
def _load_replay_video(self, video: Path) -> bool:
|
||||||
"""Open the recording a recorded video belongs to."""
|
"""Open the recording a recorded video belongs to.
|
||||||
|
|
||||||
|
A **recording** is a directory that holds ``forces.csv`` beside the video —
|
||||||
|
the same rule ``list_recordings`` uses, so the two cannot disagree about what
|
||||||
|
a recording is. A video on its own is not one: it has no force rows and no
|
||||||
|
timeline. Opening its folder anyway "succeeded" (the reader tolerates a
|
||||||
|
directory with nothing in it) and then left the transport button playing a
|
||||||
|
zero-length timeline, which is why choosing a video and pressing 回放 did
|
||||||
|
nothing at all. So a bare video is refused with what is missing, and the rail
|
||||||
|
keeps its path so 「从视频重算」 can still run the chain over it.
|
||||||
|
"""
|
||||||
video = Path(video)
|
video = Path(video)
|
||||||
folder = video.parent if video.is_file() else video
|
folder = video.parent if video.is_file() else video
|
||||||
|
if not (folder / "forces.csv").is_file():
|
||||||
|
if video.is_file():
|
||||||
|
self.toolbar.set_video_path(str(video))
|
||||||
|
self.prefs["video_path"] = str(video)
|
||||||
|
self._set_source_mode("video")
|
||||||
|
if not self.replay.is_loaded():
|
||||||
|
# Nothing else is on screen, so the reason belongs in the panel that
|
||||||
|
# would have shown the frames — not only in a status line that expires.
|
||||||
|
self.arrow_panel.clear_preview(t("playback.video_only_panel"))
|
||||||
|
self._refresh_replay_state()
|
||||||
|
self._notify(t("playback.not_a_recording", name=video.name))
|
||||||
|
return False
|
||||||
if not self._load_recording(folder):
|
if not self._load_recording(folder):
|
||||||
return False
|
return False
|
||||||
self.toolbar.set_video_path(str(video))
|
self.toolbar.set_video_path(str(video))
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _can_recompute(self) -> bool:
|
||||||
|
"""Whether 「从视频重算」 has something to read: a recording, or a video file."""
|
||||||
|
if self._view_preset != "replay":
|
||||||
|
return False
|
||||||
|
if self.replay.is_loaded():
|
||||||
|
return True
|
||||||
|
named = self.toolbar.video_path()
|
||||||
|
return bool(named) and Path(named).is_file()
|
||||||
|
|
||||||
def _load_recording(self, directory: Path) -> bool:
|
def _load_recording(self, directory: Path) -> bool:
|
||||||
if self._view_preset != "replay":
|
if self._view_preset != "replay":
|
||||||
self._apply_view_preset("replay")
|
self._apply_view_preset("replay")
|
||||||
@@ -951,10 +982,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
if segments:
|
if segments:
|
||||||
self.toolbar.set_video_path(str(segments[0]))
|
self.toolbar.set_video_path(str(segments[0]))
|
||||||
self.toolbar.set_run_checked(self._play_timer.isActive())
|
self.toolbar.set_run_checked(self._play_timer.isActive())
|
||||||
# Loading a recording is what makes a recompute pass possible at all.
|
# Loading a recording is what makes a recompute pass possible at all — but a
|
||||||
self.act_recompute.setEnabled(
|
# chosen video file is enough to recompute *from*, even when it is not a
|
||||||
self._view_preset == "replay" and self.replay.is_loaded()
|
# recording: that is the way to look at a video that has no forces.csv.
|
||||||
)
|
self.act_recompute.setEnabled(self._can_recompute())
|
||||||
self._update_run_enabled()
|
self._update_run_enabled()
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
|
|
||||||
@@ -987,7 +1018,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
def _start_playback(self) -> None:
|
def _start_playback(self) -> None:
|
||||||
duration = self.replay.duration_s()
|
duration = self.replay.duration_s()
|
||||||
if duration <= 0:
|
if duration <= 0:
|
||||||
|
# A zero-length timeline is not something to play, and silently un-checking
|
||||||
|
# the button is how "pressing 回放 does nothing" felt to the operator.
|
||||||
self.toolbar.set_run_checked(False)
|
self.toolbar.set_run_checked(False)
|
||||||
|
self._notify(t("playback.empty_recording"))
|
||||||
return
|
return
|
||||||
position = self.replay.position()
|
position = self.replay.position()
|
||||||
if position >= duration - 0.02:
|
if position >= duration - 0.02:
|
||||||
@@ -1045,7 +1079,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
run *forward*, because the chain is stateful, so it is a pass rather than
|
run *forward*, because the chain is stateful, so it is a pass rather than
|
||||||
a scrub.
|
a scrub.
|
||||||
"""
|
"""
|
||||||
wanted = bool(checked) and self._view_preset == "replay" and self.replay.is_loaded()
|
wanted = bool(checked) and self._can_recompute()
|
||||||
if self.act_recompute.isChecked() != wanted:
|
if self.act_recompute.isChecked() != wanted:
|
||||||
self.act_recompute.blockSignals(True)
|
self.act_recompute.blockSignals(True)
|
||||||
self.act_recompute.setChecked(wanted)
|
self.act_recompute.setChecked(wanted)
|
||||||
@@ -1338,9 +1372,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_finger_3d.blockSignals(True)
|
self.act_finger_3d.blockSignals(True)
|
||||||
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
|
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
|
||||||
self.act_finger_3d.blockSignals(False)
|
self.act_finger_3d.blockSignals(False)
|
||||||
self.act_recompute.setEnabled(
|
self.act_recompute.setEnabled(self._can_recompute())
|
||||||
self._view_preset == "replay" and self.replay.is_loaded()
|
|
||||||
)
|
|
||||||
self._apply_arrow_rotation_for_sensor()
|
self._apply_arrow_rotation_for_sensor()
|
||||||
|
|
||||||
def _current_source_kind(self) -> str:
|
def _current_source_kind(self) -> str:
|
||||||
@@ -1486,9 +1518,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# not a runnable sensor. While it plays it stays enabled so it can be
|
# not a runnable sensor. While it plays it stays enabled so it can be
|
||||||
# paused.
|
# paused.
|
||||||
loaded = self.replay.is_loaded()
|
loaded = self.replay.is_loaded()
|
||||||
self.toolbar.set_run_tooltip_reason(
|
if loaded:
|
||||||
"" if loaded else t("toolbar.playback_disabled")
|
reason = ""
|
||||||
)
|
elif self._can_recompute():
|
||||||
|
# A video was chosen but it is not a recording: the transport cannot
|
||||||
|
# play it, and the tooltip says which button can show it instead of
|
||||||
|
# leaving the operator pressing a dead button.
|
||||||
|
reason = t("toolbar.playback_needs_recording")
|
||||||
|
else:
|
||||||
|
reason = t("toolbar.playback_disabled")
|
||||||
|
self.toolbar.set_run_tooltip_reason(reason)
|
||||||
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive())
|
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive())
|
||||||
self.toolbar.set_record_enabled(
|
self.toolbar.set_record_enabled(
|
||||||
self._running or self.recorder.is_recording
|
self._running or self.recorder.is_recording
|
||||||
|
|||||||
@@ -1994,6 +1994,118 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
|||||||
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
|
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
|
||||||
|
make_window, record_root
|
||||||
|
):
|
||||||
|
"""A bare video has no forces.csv, so it is not something this view can replay.
|
||||||
|
|
||||||
|
The reader tolerates a directory with nothing in it, so opening the video's own
|
||||||
|
folder as a recording used to *succeed* — and then the transport played a
|
||||||
|
zero-length timeline, which is what "pressing 回放 does nothing" was.
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
bare = Path(record_root) / "bare_video.mp4"
|
||||||
|
writer = cv2.VideoWriter(
|
||||||
|
str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
||||||
|
)
|
||||||
|
for index in range(30):
|
||||||
|
writer.write(np.full((48, 64, 3), index, dtype=np.uint8))
|
||||||
|
writer.release()
|
||||||
|
assert bare.is_file()
|
||||||
|
|
||||||
|
window = _show(make_window(), (1440, 880))
|
||||||
|
window.brand_bar.btn_playback.click()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert not window.replay.is_loaded()
|
||||||
|
|
||||||
|
assert window._load_replay_video(bare) is False, (
|
||||||
|
"a video with no recording beside it was accepted as a recording"
|
||||||
|
)
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert not window.replay.is_loaded(), "an empty folder was opened as a recording"
|
||||||
|
assert not window.toolbar.btn_run.isEnabled(), (
|
||||||
|
"the transport is offered although there is nothing to play"
|
||||||
|
)
|
||||||
|
reason = window.toolbar.btn_run.toolTip()
|
||||||
|
assert "forces.csv" in reason or "recording" in reason.lower(), reason
|
||||||
|
# And what was missing, in the panel that would have shown the frames.
|
||||||
|
body = window.arrow_panel.image_label.text()
|
||||||
|
assert "forces.csv" in body or "recording" in body.lower(), body
|
||||||
|
# The way forward for a video that is not a recording: recompute from it.
|
||||||
|
assert window.act_recompute.isEnabled(), (
|
||||||
|
"a chosen video cannot be recomputed either, so the operator has no way to "
|
||||||
|
"look at it"
|
||||||
|
)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||||
|
make_window, replay_recording
|
||||||
|
):
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
window = _show(make_window(), (1440, 880))
|
||||||
|
window.brand_bar.btn_playback.click()
|
||||||
|
QApplication.processEvents()
|
||||||
|
segments = sorted(Path(replay_recording).glob("raw_*.mp4"))
|
||||||
|
assert segments, "the fixture wrote no video segment"
|
||||||
|
assert window._load_replay_video(segments[0]) is True
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window.replay.is_loaded()
|
||||||
|
assert window.toolbar.btn_run.isEnabled(), "the transport is not offered"
|
||||||
|
assert t("toolbar.playback_needs_recording") not in window.toolbar.btn_run.toolTip(), (
|
||||||
|
"a loaded recording is still described as one that cannot be played"
|
||||||
|
)
|
||||||
|
window.toolbar.btn_run.setChecked(True)
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window._play_timer.isActive(), "pressing 回放 did not start playback"
|
||||||
|
window._pause_playback()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, record_root):
|
||||||
|
"""The defensive case: a recording whose video segments are gone."""
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
directory = Path(record_root) / "no_video_recording"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / "session.json").write_text(
|
||||||
|
json.dumps({"format_version": 1, "device": {"sensor_type": "finger"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (directory / "forces.csv").open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(["session_id", "frame_id", "t_rel_s", "FN_raw"])
|
||||||
|
writer.writerow([1, 1, 0.0, 1.0])
|
||||||
|
|
||||||
|
window = _show(make_window(), (1440, 880))
|
||||||
|
window.brand_bar.btn_playback.click()
|
||||||
|
QApplication.processEvents()
|
||||||
|
if not window._load_recording(directory):
|
||||||
|
pytest.skip("this recording layout was refused before it could be played")
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window.replay.duration_s() <= 0.0
|
||||||
|
window._start_playback()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert not window._play_timer.isActive()
|
||||||
|
assert t("playback.empty_recording")[:8] in window.statusBar().currentMessage(), (
|
||||||
|
window.statusBar().currentMessage()
|
||||||
|
)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------- selection readout and notices
|
# ------------------------------------------- selection readout and notices
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2992,6 +2992,61 @@ def main() -> int:
|
|||||||
f"{replay_window.toolbar.source_summary.text()!r}",
|
f"{replay_window.toolbar.source_summary.text()!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A video that is not a recording must be refused *with a reason*, because the
|
||||||
|
# reader tolerates an empty folder: accepting it left the transport playing a
|
||||||
|
# zero-length timeline, which looked like "pressing 回放 does nothing".
|
||||||
|
import cv2 as _cv2
|
||||||
|
import numpy as _np
|
||||||
|
|
||||||
|
bare_video = replay_root / "bare_video.mp4"
|
||||||
|
bare_writer = _cv2.VideoWriter(
|
||||||
|
str(bare_video), _cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
||||||
|
)
|
||||||
|
for _index in range(10):
|
||||||
|
bare_writer.write(_np.zeros((48, 64, 3), dtype=_np.uint8))
|
||||||
|
bare_writer.release()
|
||||||
|
other_window = StudioMainWindow(
|
||||||
|
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
|
||||||
|
)
|
||||||
|
other_window.resize(1280, 720)
|
||||||
|
other_window.show()
|
||||||
|
app.processEvents()
|
||||||
|
other_window.brand_bar.btn_playback.click()
|
||||||
|
app.processEvents()
|
||||||
|
check(
|
||||||
|
other_window._load_replay_video(bare_video) is False,
|
||||||
|
"a video with no forces.csv beside it was accepted as a recording",
|
||||||
|
)
|
||||||
|
app.processEvents()
|
||||||
|
check(
|
||||||
|
not other_window.replay.is_loaded(),
|
||||||
|
"an empty folder was opened as a recording",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
not other_window.toolbar.btn_run.isEnabled(),
|
||||||
|
"the transport is offered although there is nothing to play",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"forces.csv" in other_window.toolbar.btn_run.toolTip()
|
||||||
|
or "记录" in other_window.toolbar.btn_run.toolTip(),
|
||||||
|
f"the transport does not say why it cannot play: "
|
||||||
|
f"{other_window.toolbar.btn_run.toolTip()!r}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
other_window.act_recompute.isEnabled(),
|
||||||
|
"a chosen video cannot be recomputed either: the operator has no way to view it",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"forces.csv" in other_window.arrow_panel.image_label.text()
|
||||||
|
or "记录" in other_window.arrow_panel.image_label.text(),
|
||||||
|
f"the panel does not say what is missing: "
|
||||||
|
f"{other_window.arrow_panel.image_label.text()!r}",
|
||||||
|
)
|
||||||
|
other_window.close()
|
||||||
|
app.processEvents()
|
||||||
|
replay_window.activateWindow()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
replay = replay_window.replay
|
replay = replay_window.replay
|
||||||
replay_plots = replay_window.force_plots
|
replay_plots = replay_window.force_plots
|
||||||
check(
|
check(
|
||||||
|
|||||||
Reference in New Issue
Block a user