feat(v3): PR 2 - engine.json sora→kling/veo + SmartVideoRouter
- config/engine.json: switch video_generation provider from sora to smart_router; add kling_free/veo3/seedance2 engine options; update optional_keys (KLING_API_KEY, FAL_API_KEY); keep legacy entries - bots/converters/smart_video_router.py: new SmartVideoRouter class with budget-aware engine selection, daily state tracking, Kling stub implementation, and ffmpeg_slides fallback chain Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
bots/converters/smart_video_router.py [NEW]
|
||||||
|
|
||||||
|
Budget-aware video engine selection and fallback router.
|
||||||
|
|
||||||
|
Selection logic:
|
||||||
|
1. Kling free credits remaining? → use Kling
|
||||||
|
2. Budget allows paid? → cheapest quality engine
|
||||||
|
3. Daily limit hit? → FFmpeg fallback
|
||||||
|
4. Any engine fails? → next in priority (no retry on same)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from bots.converters.smart_video_router import SmartVideoRouter
|
||||||
|
router = SmartVideoRouter(resolved_config)
|
||||||
|
engine = router.select(duration_sec=30, needs_audio=True)
|
||||||
|
path = router.generate(prompt, engine, '/tmp/out.mp4')
|
||||||
|
|
||||||
|
Test mode:
|
||||||
|
python -m bots.converters.smart_video_router --test
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
STATE_FILE = DATA_DIR / 'video_router_state.json'
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.FileHandler(LOG_DIR / 'smart_video_router.log', encoding='utf-8')
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class SmartVideoRouter:
|
||||||
|
"""
|
||||||
|
Budget-aware video engine selection and fallback.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
1. Kling free credits remaining? → use Kling
|
||||||
|
2. Budget allows paid? → cheapest quality engine
|
||||||
|
3. Daily limit hit? → FFmpeg fallback
|
||||||
|
4. Any engine fails? → next in priority (no retry on same)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, resolved_config: dict):
|
||||||
|
"""
|
||||||
|
resolved_config: output from ConfigResolver.resolve(), or raw engine.json dict.
|
||||||
|
Expects video_generation key with provider/options structure.
|
||||||
|
"""
|
||||||
|
video_cfg = resolved_config.get('video_generation', {})
|
||||||
|
opts = video_cfg.get('options', {})
|
||||||
|
router_cfg = opts.get('smart_router', {})
|
||||||
|
|
||||||
|
self.priority: list = router_cfg.get(
|
||||||
|
'priority', ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides']
|
||||||
|
)
|
||||||
|
self.daily_cost_limit_usd: float = router_cfg.get('daily_cost_limit_usd', 0.50)
|
||||||
|
self.prefer_free_first: bool = router_cfg.get('prefer_free_first', True)
|
||||||
|
self.fallback_engine: str = router_cfg.get('fallback', 'ffmpeg_slides')
|
||||||
|
|
||||||
|
self.engine_opts: dict = opts # all engine option blocks
|
||||||
|
self.state: dict = self._get_state()
|
||||||
|
|
||||||
|
# ── State management ────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_state(self) -> dict:
|
||||||
|
"""Load daily state from disk; reset if date has changed."""
|
||||||
|
today = str(date.today())
|
||||||
|
default = {
|
||||||
|
'date': today,
|
||||||
|
'cost_usd': 0.0,
|
||||||
|
'kling_credits_used': 0,
|
||||||
|
}
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
saved = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||||
|
if saved.get('date') == today:
|
||||||
|
return saved
|
||||||
|
# New day — reset counters, keep structure
|
||||||
|
logger.info(f"날짜 변경 감지 ({saved.get('date')} → {today}): 라우터 상태 초기화")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"상태 파일 읽기 실패: {e}")
|
||||||
|
self._save_state(default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _save_state(self, state: Optional[dict] = None) -> None:
|
||||||
|
"""Persist router state to data/video_router_state.json."""
|
||||||
|
target = state if state is not None else self.state
|
||||||
|
try:
|
||||||
|
STATE_FILE.write_text(
|
||||||
|
json.dumps(target, ensure_ascii=False, indent=2),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"상태 파일 저장 실패: {e}")
|
||||||
|
|
||||||
|
# ── Engine availability checks ───────────────────────────
|
||||||
|
|
||||||
|
def _has_api_key(self, engine_name: str) -> bool:
|
||||||
|
"""Return True if the engine's API key env var is set and non-empty."""
|
||||||
|
cfg = self.engine_opts.get(engine_name, {})
|
||||||
|
key_env = cfg.get('api_key_env', '')
|
||||||
|
if not key_env:
|
||||||
|
# ffmpeg_slides has no API key requirement
|
||||||
|
return True
|
||||||
|
return bool(os.getenv(key_env, '').strip())
|
||||||
|
|
||||||
|
def _kling_credits_available(self) -> bool:
|
||||||
|
"""Return True if Kling free credits are still available today."""
|
||||||
|
kling_cfg = self.engine_opts.get('kling_free', {})
|
||||||
|
daily_credits = kling_cfg.get('free_daily_credits', 66)
|
||||||
|
used = self.state.get('kling_credits_used', 0)
|
||||||
|
return used < daily_credits
|
||||||
|
|
||||||
|
def _budget_allows(self, engine_name: str, duration_sec: float) -> bool:
|
||||||
|
"""Return True if engine cost fits within remaining daily budget."""
|
||||||
|
cfg = self.engine_opts.get(engine_name, {})
|
||||||
|
cost_per_sec = cfg.get('cost_per_sec', 0)
|
||||||
|
if cost_per_sec == 0:
|
||||||
|
return True
|
||||||
|
estimated_cost = cost_per_sec * duration_sec
|
||||||
|
spent = self.state.get('cost_usd', 0.0)
|
||||||
|
return (spent + estimated_cost) <= self.daily_cost_limit_usd
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def select(self, duration_sec: float, needs_audio: bool) -> str:
|
||||||
|
"""
|
||||||
|
Select best available engine for the given clip duration.
|
||||||
|
Returns engine name string (never empty — falls back to ffmpeg_slides).
|
||||||
|
"""
|
||||||
|
self.state = self._get_state() # refresh in case of date change
|
||||||
|
|
||||||
|
for engine in self.priority:
|
||||||
|
if engine == 'ffmpeg_slides':
|
||||||
|
logger.info("영상 라우터: ffmpeg_slides 선택 (최종 폴백)")
|
||||||
|
return 'ffmpeg_slides'
|
||||||
|
|
||||||
|
if engine == 'kling_free':
|
||||||
|
if self._has_api_key('kling_free') and self._kling_credits_available():
|
||||||
|
logger.info("영상 라우터: kling_free 선택 (무료 크레딧 잔여)")
|
||||||
|
return 'kling_free'
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Paid engines (veo3, seedance2, ...)
|
||||||
|
if self._has_api_key(engine) and self._budget_allows(engine, duration_sec):
|
||||||
|
logger.info(f"영상 라우터: {engine} 선택 (예산 내 유료 엔진)")
|
||||||
|
return engine
|
||||||
|
|
||||||
|
# Final safety net
|
||||||
|
logger.info("영상 라우터: ffmpeg_slides 최종 폴백 선택")
|
||||||
|
return self.fallback_engine
|
||||||
|
|
||||||
|
def generate(self, prompt, engine: str, output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a video clip using the specified engine.
|
||||||
|
|
||||||
|
prompt: ComposedPrompt object with .text attribute, or plain str.
|
||||||
|
Returns path to output MP4, or '' on failure.
|
||||||
|
"""
|
||||||
|
# Normalise prompt to str
|
||||||
|
if hasattr(prompt, 'text'):
|
||||||
|
prompt_text = prompt.text
|
||||||
|
else:
|
||||||
|
prompt_text = str(prompt)
|
||||||
|
|
||||||
|
logger.info(f"영상 생성 시작: 엔진={engine}, 출력={output_path}")
|
||||||
|
|
||||||
|
if engine == 'kling_free':
|
||||||
|
result = self._generate_kling(prompt_text, output_path)
|
||||||
|
elif engine == 'ffmpeg_slides':
|
||||||
|
result = self._generate_ffmpeg(prompt_text, output_path)
|
||||||
|
else:
|
||||||
|
# veo3, seedance2, runway, etc. — stub: not yet implemented
|
||||||
|
logger.warning(f"{engine} 구현 미완성 — 폴백 트리거")
|
||||||
|
result = ''
|
||||||
|
|
||||||
|
if result:
|
||||||
|
# Update cost tracking
|
||||||
|
cfg = self.engine_opts.get(engine, {})
|
||||||
|
cost_per_sec = cfg.get('cost_per_sec', 0)
|
||||||
|
if cost_per_sec > 0:
|
||||||
|
# Estimate 30s clip cost as a rough default
|
||||||
|
self.state['cost_usd'] = round(
|
||||||
|
self.state.get('cost_usd', 0.0) + cost_per_sec * 30, 4
|
||||||
|
)
|
||||||
|
self._save_state()
|
||||||
|
logger.info(f"영상 생성 완료: {result}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"영상 생성 실패: 엔진={engine}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def on_failure(self, engine: str, error: str) -> str:
|
||||||
|
"""
|
||||||
|
Called when an engine fails mid-generation.
|
||||||
|
Returns next engine to try, or 'ffmpeg_slides' as final fallback.
|
||||||
|
"""
|
||||||
|
logger.warning(f"엔진 실패 처리: {engine} — {error}")
|
||||||
|
try:
|
||||||
|
idx = self.priority.index(engine)
|
||||||
|
next_engines = self.priority[idx + 1:]
|
||||||
|
except ValueError:
|
||||||
|
next_engines = []
|
||||||
|
|
||||||
|
for candidate in next_engines:
|
||||||
|
logger.info(f"다음 엔진 시도: {candidate}")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
logger.info("모든 엔진 소진 — ffmpeg_slides 최종 폴백")
|
||||||
|
return 'ffmpeg_slides'
|
||||||
|
|
||||||
|
# ── Engine implementations ────────────────────────────────
|
||||||
|
|
||||||
|
def _generate_kling(self, prompt_text: str, output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Kling free tier stub implementation.
|
||||||
|
|
||||||
|
The actual Kling API integration is pending (V3.1).
|
||||||
|
For now, log that the call would be made and fall back to ffmpeg_slides.
|
||||||
|
"""
|
||||||
|
api_key = os.getenv('KLING_API_KEY', '')
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("KLING_API_KEY 미설정 — ffmpeg_slides 폴백")
|
||||||
|
return self._generate_ffmpeg(prompt_text, output_path)
|
||||||
|
|
||||||
|
kling_cfg = self.engine_opts.get('kling_free', {})
|
||||||
|
api_url = kling_cfg.get('api_url', 'https://api.klingai.com/v1')
|
||||||
|
|
||||||
|
# Stub: log what would happen, then fall back
|
||||||
|
logger.info(
|
||||||
|
f"[스텁] Kling API 호출 예정: POST {api_url}/videos/text2video "
|
||||||
|
f"(프롬프트: {prompt_text[:60]}...) — 실제 통합 V3.1에서 구현 예정"
|
||||||
|
)
|
||||||
|
logger.info("Kling 스텁 실행 — ffmpeg_slides로 폴백하여 영상 생성")
|
||||||
|
|
||||||
|
# Track credit usage even for stub (as if 1 credit consumed per call)
|
||||||
|
self.state['kling_credits_used'] = self.state.get('kling_credits_used', 0) + 1
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
return self._generate_ffmpeg(prompt_text, output_path)
|
||||||
|
|
||||||
|
def _generate_ffmpeg(self, prompt_text: str, output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a minimal single-scene video using FFmpegSlidesEngine.
|
||||||
|
Accepts a plain text prompt and wraps it into a scene list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bots.converters.video_engine import FFmpegSlidesEngine
|
||||||
|
ffmpeg_cfg = self.engine_opts.get('ffmpeg_slides', {})
|
||||||
|
engine = FFmpegSlidesEngine(ffmpeg_cfg)
|
||||||
|
|
||||||
|
# Wrap prompt into minimal scene structure expected by FFmpegSlidesEngine
|
||||||
|
scenes = [
|
||||||
|
{
|
||||||
|
'text': prompt_text[:200], # truncate if very long
|
||||||
|
'type': 'headline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return engine.generate(scenes, output_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"FFmpegSlidesEngine 실패: {e}")
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module entry point (--test mode) ─────────────────────────
|
||||||
|
|
||||||
|
def _load_engine_config() -> dict:
|
||||||
|
"""Load engine.json from config directory."""
|
||||||
|
config_path = BASE_DIR / 'config' / 'engine.json'
|
||||||
|
try:
|
||||||
|
return json.loads(config_path.read_text(encoding='utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"engine.json 로드 실패: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_test() -> None:
|
||||||
|
"""Print current router state and selected engine for a 30s clip."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("SmartVideoRouter - 테스트 모드")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
config = _load_engine_config()
|
||||||
|
if not config:
|
||||||
|
print("[오류] engine.json 로드 실패")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
router = SmartVideoRouter(config)
|
||||||
|
|
||||||
|
print("\n[현재 상태]")
|
||||||
|
state = router._get_state()
|
||||||
|
for k, v in state.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
|
||||||
|
print("\n[엔진 우선순위]")
|
||||||
|
for i, eng in enumerate(router.priority, 1):
|
||||||
|
has_key = router._has_api_key(eng)
|
||||||
|
key_env = router.engine_opts.get(eng, {}).get('api_key_env', '(없음)')
|
||||||
|
print(f" {i}. {eng} - API키={key_env} 설정됨={has_key}")
|
||||||
|
|
||||||
|
print("\n[30초 클립 엔진 선택]")
|
||||||
|
selected = router.select(duration_sec=30, needs_audio=True)
|
||||||
|
print(f" → 선택된 엔진: {selected}")
|
||||||
|
|
||||||
|
cost_spent = state.get('cost_usd', 0.0)
|
||||||
|
cost_limit = router.daily_cost_limit_usd
|
||||||
|
kling_used = state.get('kling_credits_used', 0)
|
||||||
|
kling_limit = router.engine_opts.get('kling_free', {}).get('free_daily_credits', 66)
|
||||||
|
print(f"\n[예산 현황]")
|
||||||
|
print(f" 일일 비용: ${cost_spent:.4f} / ${cost_limit:.2f}")
|
||||||
|
print(f" Kling 크레딧: {kling_used} / {kling_limit} 사용")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if '--test' in sys.argv:
|
||||||
|
_run_test()
|
||||||
|
else:
|
||||||
|
print("사용법: python -m bots.converters.smart_video_router --test")
|
||||||
+38
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
||||||
"_updated": "2026-03-26",
|
"_updated": "2026-03-29",
|
||||||
"writing": {
|
"writing": {
|
||||||
"provider": "openclaw",
|
"provider": "openclaw",
|
||||||
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
|
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
|
||||||
@@ -75,8 +75,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"video_generation": {
|
"video_generation": {
|
||||||
"provider": "sora",
|
"provider": "smart_router",
|
||||||
"options": {
|
"options": {
|
||||||
|
"smart_router": {
|
||||||
|
"priority": ["kling_free", "veo3", "seedance2", "ffmpeg_slides"],
|
||||||
|
"daily_cost_limit_usd": 0.50,
|
||||||
|
"prefer_free_first": true,
|
||||||
|
"fallback": "ffmpeg_slides"
|
||||||
|
},
|
||||||
|
"kling_free": {
|
||||||
|
"api_url": "https://api.klingai.com/v1",
|
||||||
|
"api_key_env": "KLING_API_KEY",
|
||||||
|
"free_daily_credits": 66,
|
||||||
|
"mode": "standard",
|
||||||
|
"resolution": "720p",
|
||||||
|
"aspect_ratio": "9:16",
|
||||||
|
"audio": true,
|
||||||
|
"cost_per_sec": 0
|
||||||
|
},
|
||||||
|
"veo3": {
|
||||||
|
"api_key_env": "GEMINI_API_KEY",
|
||||||
|
"model": "veo-3.1",
|
||||||
|
"resolution": "720p",
|
||||||
|
"aspect_ratio": "9:16",
|
||||||
|
"audio": true,
|
||||||
|
"cost_per_sec": 0.03
|
||||||
|
},
|
||||||
|
"seedance2": {
|
||||||
|
"provider": "fal.ai",
|
||||||
|
"api_key_env": "FAL_API_KEY",
|
||||||
|
"model": "seedance-2.0",
|
||||||
|
"resolution": "720p",
|
||||||
|
"audio": true,
|
||||||
|
"cost_per_sec": 0.022
|
||||||
|
},
|
||||||
"ffmpeg_slides": {
|
"ffmpeg_slides": {
|
||||||
"resolution": "1080x1920",
|
"resolution": "1080x1920",
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
@@ -88,6 +120,7 @@
|
|||||||
"burn_subtitles": true
|
"burn_subtitles": true
|
||||||
},
|
},
|
||||||
"seedance": {
|
"seedance": {
|
||||||
|
"_comment": "레거시 — seedance2/FAL_API_KEY로 대체됨",
|
||||||
"api_url": "https://api.seedance2.ai/v1/generate",
|
"api_url": "https://api.seedance2.ai/v1/generate",
|
||||||
"api_key_env": "SEEDANCE_API_KEY",
|
"api_key_env": "SEEDANCE_API_KEY",
|
||||||
"resolution": "1080x1920",
|
"resolution": "1080x1920",
|
||||||
@@ -95,11 +128,6 @@
|
|||||||
"audio": true,
|
"audio": true,
|
||||||
"fallback": "ffmpeg_slides"
|
"fallback": "ffmpeg_slides"
|
||||||
},
|
},
|
||||||
"sora": {
|
|
||||||
"comment": "OpenAI Sora — API 접근 가능 시 활성화",
|
|
||||||
"api_key_env": "OPENAI_API_KEY",
|
|
||||||
"fallback": "ffmpeg_slides"
|
|
||||||
},
|
|
||||||
"runway": {
|
"runway": {
|
||||||
"api_key_env": "RUNWAY_API_KEY",
|
"api_key_env": "RUNWAY_API_KEY",
|
||||||
"model": "gen3a_turbo",
|
"model": "gen3a_turbo",
|
||||||
@@ -108,7 +136,7 @@
|
|||||||
"fallback": "ffmpeg_slides"
|
"fallback": "ffmpeg_slides"
|
||||||
},
|
},
|
||||||
"veo": {
|
"veo": {
|
||||||
"comment": "Google Veo 3.1 — API 접근 가능 시 활성화",
|
"comment": "Google Veo 3.1 레거시 — veo3로 대체됨",
|
||||||
"api_key_env": "GEMINI_API_KEY",
|
"api_key_env": "GEMINI_API_KEY",
|
||||||
"fallback": "ffmpeg_slides"
|
"fallback": "ffmpeg_slides"
|
||||||
}
|
}
|
||||||
@@ -170,7 +198,8 @@
|
|||||||
"cta": "팔로우하면 매일 이런 정보를 받습니다"
|
"cta": "팔로우하면 매일 이런 정보를 받습니다"
|
||||||
},
|
},
|
||||||
"optional_keys": {
|
"optional_keys": {
|
||||||
"SEEDANCE_API_KEY": "Seedance 2.0 AI 영상 생성",
|
"KLING_API_KEY": "Kling 3.0 AI 영상 생성 (66 무료 크레딧/일)",
|
||||||
|
"FAL_API_KEY": "Seedance 2.0 AI 영상 생성 (fal.ai)",
|
||||||
"ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS",
|
"ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS",
|
||||||
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
||||||
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
||||||
|
|||||||
Reference in New Issue
Block a user