Thought I had staged it
This commit is contained in:
@@ -6,7 +6,7 @@ class ISpeakingService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def get_speaking_part(
|
||||
self, part: int, topic: str, difficulty: str, second_topic: Optional[str] = None
|
||||
self, part: int, topic: str, second_topic: str, difficulty: str
|
||||
) -> Dict:
|
||||
pass
|
||||
|
||||
@@ -14,16 +14,3 @@ class ISpeakingService(ABC):
|
||||
async def grade_speaking_task(self, task: int, answers: List[Dict]) -> Dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_videos_and_save_to_db(self, exercises: List[Dict], template: Dict, req_id: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def generate_video(
|
||||
self, part: int, avatar: str, topic: str, questions: list[str],
|
||||
*,
|
||||
second_topic: Optional[str] = None,
|
||||
prompts: Optional[list[str]] = None,
|
||||
suffix: Optional[str] = None,
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class IVideoGeneratorService(ABC):
|
||||
|
||||
def __init__(self, avatars: Dict):
|
||||
self._avatars = avatars
|
||||
|
||||
async def get_avatars(self) -> List[Dict]:
|
||||
return [
|
||||
{"name": name, "gender": data["avatar_gender"]}
|
||||
for name, data in self._avatars.items()
|
||||
]
|
||||
|
||||
@abstractmethod
|
||||
async def create_video(self, text: str, avatar: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def pool_status(self, video_id: str):
|
||||
pass
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.repositories.abc import IFileStorage, IDocumentStore
|
||||
from app.services.abc import ISpeakingService, ILLMService, IVideoGeneratorService, ISpeechToTextService
|
||||
from app.configs.constants import (
|
||||
FieldsAndExercises, GPTModels, TemperatureSettings,
|
||||
ELAIAvatars, FilePaths
|
||||
FilePaths
|
||||
)
|
||||
from app.helpers import TextHelper
|
||||
|
||||
@@ -17,14 +17,12 @@ from app.helpers import TextHelper
|
||||
class SpeakingService(ISpeakingService):
|
||||
|
||||
def __init__(
|
||||
self, llm: ILLMService, vid_gen: IVideoGeneratorService,
|
||||
file_storage: IFileStorage, document_store: IDocumentStore,
|
||||
self, llm: ILLMService,
|
||||
file_storage: IFileStorage,
|
||||
stt: ISpeechToTextService
|
||||
):
|
||||
self._llm = llm
|
||||
self._vid_gen = vid_gen
|
||||
self._file_storage = file_storage
|
||||
self._document_store = document_store
|
||||
self._stt = stt
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,7 +100,7 @@ class SpeakingService(ISpeakingService):
|
||||
}
|
||||
|
||||
async def get_speaking_part(
|
||||
self, part: int, topic: str, difficulty: str, second_topic: Optional[str] = None
|
||||
self, part: int, topic: str, second_topic: str, difficulty: str
|
||||
) -> Dict:
|
||||
task_values = self._tasks[f'task_{part}']['get']
|
||||
|
||||
@@ -416,190 +414,6 @@ class SpeakingService(ISpeakingService):
|
||||
)
|
||||
return response["fixed_text"]
|
||||
|
||||
async def create_videos_and_save_to_db(self, exercises, template, req_id):
|
||||
template = await self._create_video_per_part(exercises, template, 1)
|
||||
template = await self._create_video_per_part(exercises, template, 2)
|
||||
template = await self._create_video_per_part(exercises, template, 3)
|
||||
|
||||
await self._document_store.save_to_db_with_id("speaking", template, req_id)
|
||||
self._logger.info(f'Saved speaking to DB with id {req_id} : {str(template)}')
|
||||
|
||||
async def _create_video_per_part(self, exercises: List[Dict], template: Dict, part: int):
|
||||
avatar = (random.choice(list(ELAIAvatars))).name
|
||||
template_index = part - 1
|
||||
|
||||
# Using list comprehension to find the element with the desired value in the 'type' field
|
||||
found_exercises = [element for element in exercises if element.get('type') == part]
|
||||
|
||||
# Check if any elements were found
|
||||
if found_exercises:
|
||||
exercise = found_exercises[0]
|
||||
self._logger.info(f'Creating video for speaking part {part}')
|
||||
if part in {1, 3}:
|
||||
questions = []
|
||||
for question in exercise["questions"]:
|
||||
result = await self._create_video(
|
||||
question,
|
||||
avatar,
|
||||
f'Failed to create video for part {part} question: {str(exercise["question"])}'
|
||||
)
|
||||
if result is not None:
|
||||
video = {
|
||||
"text": question,
|
||||
"video_path": result["video_path"],
|
||||
"video_url": result["video_url"]
|
||||
}
|
||||
questions.append(video)
|
||||
|
||||
template["exercises"][template_index]["prompts"] = questions
|
||||
if part == 1:
|
||||
template["exercises"][template_index]["first_title"] = exercise["first_topic"]
|
||||
template["exercises"][template_index]["second_title"] = exercise["second_topic"]
|
||||
else:
|
||||
template["exercises"][template_index]["title"] = exercise["topic"]
|
||||
else:
|
||||
result = await self._create_video(
|
||||
exercise["question"],
|
||||
avatar,
|
||||
f'Failed to create video for part {part} question: {str(exercise["question"])}'
|
||||
)
|
||||
if result is not None:
|
||||
template["exercises"][template_index]["prompts"] = exercise["prompts"]
|
||||
template["exercises"][template_index]["text"] = exercise["question"]
|
||||
template["exercises"][template_index]["title"] = exercise["topic"]
|
||||
template["exercises"][template_index]["video_url"] = result["video_url"]
|
||||
template["exercises"][template_index]["video_path"] = result["video_path"]
|
||||
|
||||
if not found_exercises:
|
||||
template["exercises"].pop(template_index)
|
||||
|
||||
return template
|
||||
|
||||
async def generate_video(
|
||||
self, part: int, avatar: str, topic: str, questions: list[str],
|
||||
*,
|
||||
second_topic: Optional[str] = None,
|
||||
prompts: Optional[list[str]] = None,
|
||||
suffix: Optional[str] = None,
|
||||
):
|
||||
params = locals()
|
||||
params.pop('self')
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
self._logger.info(
|
||||
f'POST - generate_video_{part} - Received request to generate video {part}. '
|
||||
f'Use this id to track the logs: {request_id} - Request data: " + {params}'
|
||||
)
|
||||
|
||||
part_questions = self._get_part_questions(part, questions, avatar)
|
||||
videos = []
|
||||
|
||||
self._logger.info(f'POST - generate_video_{part} - {request_id} - Creating videos for speaking part {part}.')
|
||||
for question in part_questions:
|
||||
self._logger.info(f'POST - generate_video_{part} - {request_id} - Creating video for question: {question}')
|
||||
result = await self._create_video(
|
||||
question,
|
||||
avatar,
|
||||
'POST - generate_video_{p} - {r} - Failed to create video for part {p} question: {q}'.format(
|
||||
p=part, r=request_id, q=question
|
||||
)
|
||||
)
|
||||
if result is not None:
|
||||
self._logger.info(f'POST - generate_video_{part} - {request_id} - Video created')
|
||||
self._logger.info(
|
||||
f'POST - generate_video_{part} - {request_id} - Uploaded video to firebase: {result["video_url"]}'
|
||||
)
|
||||
video = {
|
||||
"text": question,
|
||||
"video_path": result["video_path"],
|
||||
"video_url": result["video_url"]
|
||||
}
|
||||
videos.append(video)
|
||||
|
||||
if part == 2 and len(videos) == 0:
|
||||
raise Exception(f'Failed to create video for part 2 question: {questions[0]}')
|
||||
|
||||
return self._get_part_response(part, topic, videos, second_topic, prompts, suffix)
|
||||
|
||||
@staticmethod
|
||||
def _get_part_questions(part: int, questions: list[str], avatar: str):
|
||||
part_questions: list[str] = []
|
||||
|
||||
if part == 1:
|
||||
id_to_name = {
|
||||
"5912afa7c77c47d3883af3d874047aaf": "MATTHEW",
|
||||
"9e58d96a383e4568a7f1e49df549e0e4": "VERA",
|
||||
"d2cdd9c0379a4d06ae2afb6e5039bd0c": "EDWARD",
|
||||
"045cb5dcd00042b3a1e4f3bc1c12176b": "TANYA",
|
||||
"1ae1e5396cc444bfad332155fdb7a934": "KAYLA",
|
||||
"0ee6aa7cc1084063a630ae514fccaa31": "JEROME",
|
||||
"5772cff935844516ad7eeff21f839e43": "TYLER",
|
||||
|
||||
}
|
||||
part_questions.extend(
|
||||
[
|
||||
"Hello my name is " + id_to_name.get(avatar) + ", what is yours?",
|
||||
"Do you work or do you study?",
|
||||
*questions
|
||||
]
|
||||
)
|
||||
elif part == 2:
|
||||
# Removed as the examiner should not say what is on the card.
|
||||
# question = question + " In your answer you should consider: " + " ".join(prompts) + suffix
|
||||
part_questions.append(f'{questions[0]}\nYou have 1 minute to take notes.')
|
||||
elif part == 3:
|
||||
part_questions = questions
|
||||
|
||||
return part_questions
|
||||
|
||||
@staticmethod
|
||||
def _get_part_response(
|
||||
part: int,
|
||||
topic: str,
|
||||
videos: list[dict],
|
||||
second_topic: Optional[str],
|
||||
prompts: Optional[list[str]],
|
||||
suffix: Optional[str]
|
||||
):
|
||||
response = {}
|
||||
if part == 1:
|
||||
response = {
|
||||
"prompts": videos,
|
||||
"first_title": topic,
|
||||
"second_title": second_topic,
|
||||
"type": "interactiveSpeaking"
|
||||
}
|
||||
if part == 2:
|
||||
response = {
|
||||
"prompts": prompts,
|
||||
"title": topic,
|
||||
"suffix": suffix,
|
||||
"type": "speaking",
|
||||
# includes text, video_url and video_path
|
||||
**videos[0]
|
||||
}
|
||||
if part == 3:
|
||||
response = {
|
||||
"prompts": videos,
|
||||
"title": topic,
|
||||
"type": "interactiveSpeaking",
|
||||
}
|
||||
|
||||
response["id"] = str(uuid.uuid4())
|
||||
return response
|
||||
|
||||
async def _create_video(self, question: str, avatar: str, error_message: str):
|
||||
result = await self._vid_gen.create_video(question, avatar)
|
||||
if result is not None:
|
||||
sound_file_path = FilePaths.VIDEO_FILES_PATH + result
|
||||
firebase_file_path = FilePaths.FIREBASE_SPEAKING_VIDEO_FILES_PATH + result
|
||||
url = await self._file_storage.upload_file_firebase_get_url(firebase_file_path, sound_file_path)
|
||||
return {
|
||||
"video_path": firebase_file_path,
|
||||
"video_url": url
|
||||
}
|
||||
self._logger.error(error_message)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _grade_template():
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from asyncio import sleep
|
||||
from copy import deepcopy
|
||||
|
||||
import aiofiles
|
||||
from charset_normalizer.md import getLogger
|
||||
|
||||
from logging import getLogger
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.configs.constants import ELAIAvatars
|
||||
from app.dtos.video import Task, TaskStatus
|
||||
from app.services.abc import IVideoGeneratorService
|
||||
|
||||
|
||||
@@ -17,7 +10,9 @@ class ELAI(IVideoGeneratorService):
|
||||
|
||||
_ELAI_ENDPOINT = 'https://apis.elai.io/api/v1/videos'
|
||||
|
||||
def __init__(self, client: AsyncClient, token: str, conf: dict):
|
||||
def __init__(self, client: AsyncClient, token: str, avatars: dict, *, conf: dict):
|
||||
super().__init__(deepcopy(avatars))
|
||||
|
||||
self._http_client = client
|
||||
self._conf = deepcopy(conf)
|
||||
self._logger = getLogger(__name__)
|
||||
@@ -31,14 +26,13 @@ class ELAI(IVideoGeneratorService):
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
|
||||
|
||||
async def create_video(self, text: str, avatar: str):
|
||||
avatar_url = ELAIAvatars[avatar].value.get("avatar_url")
|
||||
avatar_code = ELAIAvatars[avatar].value.get("avatar_code")
|
||||
avatar_gender = ELAIAvatars[avatar].value.get("avatar_gender")
|
||||
avatar_canvas = ELAIAvatars[avatar].value.get("avatar_canvas")
|
||||
voice_id = ELAIAvatars[avatar].value.get("voice_id")
|
||||
voice_provider = ELAIAvatars[avatar].value.get("voice_provider")
|
||||
avatar_url = self._avatars[avatar].get("avatar_url")
|
||||
avatar_code = self._avatars[avatar].get("avatar_code")
|
||||
avatar_gender = self._avatars[avatar].get("avatar_gender")
|
||||
avatar_canvas = self._avatars[avatar].get("avatar_canvas")
|
||||
voice_id = self._avatars[avatar].get("voice_id")
|
||||
voice_provider = self._avatars[avatar].get("voice_provider")
|
||||
|
||||
self._conf["slides"][0]["canvas"]["objects"][0]["src"] = avatar_url
|
||||
self._conf["slides"]["avatar"] = {
|
||||
@@ -59,37 +53,32 @@ class ELAI(IVideoGeneratorService):
|
||||
|
||||
if video_id:
|
||||
await self._http_client.post(f'{self._ELAI_ENDPOINT}/render/{video_id}', headers=self._GET_HEADER)
|
||||
return Task(
|
||||
result=video_id,
|
||||
status=TaskStatus.STARTED,
|
||||
)
|
||||
else:
|
||||
return Task(status=TaskStatus.ERROR)
|
||||
|
||||
while True:
|
||||
response = await self._http_client.get(f'{self._ELAI_ENDPOINT}/{video_id}', headers=self._GET_HEADER)
|
||||
response_data = response.json()
|
||||
async def pool_status(self, video_id: str) -> Task:
|
||||
response = await self._http_client.get(f'{self._ELAI_ENDPOINT}/{video_id}', headers=self._GET_HEADER)
|
||||
response_data = response.json()
|
||||
|
||||
if response_data['status'] == 'ready':
|
||||
self._logger.info(response_data)
|
||||
|
||||
download_url = response_data.get('url')
|
||||
output_directory = 'download-video/'
|
||||
output_filename = video_id + '.mp4'
|
||||
|
||||
response = await self._http_client.get(download_url)
|
||||
|
||||
if response.status_code == 200:
|
||||
os.makedirs(output_directory, exist_ok=True)
|
||||
output_path = os.path.join(output_directory, output_filename)
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
self._logger.info(f"File '{output_filename}' downloaded successfully.")
|
||||
return output_filename
|
||||
|
||||
else:
|
||||
self._logger.error(f"Failed to download file. Status code: {response.status_code}")
|
||||
return None
|
||||
|
||||
elif response_data['status'] == 'failed':
|
||||
self._logger.error('Video creation failed.')
|
||||
break
|
||||
else:
|
||||
self._logger.info('Video is still processing. Checking again in 10 seconds...')
|
||||
await sleep(10)
|
||||
if response_data['status'] == 'ready':
|
||||
self._logger.info(response_data)
|
||||
return Task(
|
||||
status=TaskStatus.COMPLETED,
|
||||
result=response_data.get('url')
|
||||
)
|
||||
elif response_data['status'] == 'failed':
|
||||
self._logger.error('Video creation failed.')
|
||||
return Task(
|
||||
status=TaskStatus.ERROR,
|
||||
result=response_data.get('url')
|
||||
)
|
||||
else:
|
||||
self._logger.info('Video is still processing.')
|
||||
return Task(
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
result=video_id
|
||||
)
|
||||
|
||||
58
app/services/impl/third_parties/elai/avatars.json
Normal file
58
app/services/impl/third_parties/elai/avatars.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"Gia": {
|
||||
"avatar_code": "gia.business",
|
||||
"avatar_gender": "female",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/gia/business/gia_business.png",
|
||||
"avatar_canvas": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/gia/business/gia_business.png",
|
||||
"voice_id": "EXAVITQu4vr4xnSDxMaL",
|
||||
"voice_provider": "elevenlabs"
|
||||
},
|
||||
"Vadim": {
|
||||
"avatar_code": "vadim.business",
|
||||
"avatar_gender": "male",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/vadim/business/vadim_business.png",
|
||||
"avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/vadim/business/vadim_business.png",
|
||||
"voice_id": "flq6f7yk4E4fJM5XTYuZ",
|
||||
"voice_provider": "elevenlabs"
|
||||
},
|
||||
"Orhan": {
|
||||
"avatar_code": "orhan.business",
|
||||
"avatar_gender": "male",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/orhan/business/orhan.png",
|
||||
"avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/orhan/business/orhan.png",
|
||||
"voice_id": "en-US-AndrewMultilingualNeural",
|
||||
"voice_provider": "azure"
|
||||
},
|
||||
"Flora": {
|
||||
"avatar_code": "flora.business",
|
||||
"avatar_gender": "female",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/flora/business/flora_business.png",
|
||||
"avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/flora/business/flora_business.png",
|
||||
"voice_id": "en-US-JaneNeural",
|
||||
"voice_provider": "azure"
|
||||
},
|
||||
"Scarlett": {
|
||||
"avatar_code": "scarlett.business",
|
||||
"avatar_gender": "female",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/scarlett/business/scarlett_business.png",
|
||||
"avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/scarlett/business/scarlett_business.png",
|
||||
"voice_id": "en-US-NancyNeural",
|
||||
"voice_provider": "azure"
|
||||
},
|
||||
"Parker": {
|
||||
"avatar_code": "parker.casual",
|
||||
"avatar_gender": "male",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/parker/casual/parker_casual.png",
|
||||
"avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/parker/casual/parker_casual.png",
|
||||
"voice_id": "en-US-TonyNeural",
|
||||
"voice_provider": "azure"
|
||||
},
|
||||
"Ethan": {
|
||||
"avatar_code": "ethan.business",
|
||||
"avatar_gender": "male",
|
||||
"avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/ethan/business/ethan_business_low.png",
|
||||
"avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/ethan/business/ethan_business_low.png",
|
||||
"voice_id": "en-US-JasonNeural",
|
||||
"voice_provider": "azure"
|
||||
}
|
||||
}
|
||||
93
app/services/impl/third_parties/heygen/__init__.py
Normal file
93
app/services/impl/third_parties/heygen/__init__.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import random
|
||||
from copy import deepcopy
|
||||
|
||||
import aiofiles
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.dtos.video import Task, TaskStatus
|
||||
from app.services.abc import IVideoGeneratorService
|
||||
|
||||
|
||||
class Heygen(IVideoGeneratorService):
|
||||
|
||||
_GET_VIDEO_URL = 'https://api.heygen.com/v1/video_status.get'
|
||||
|
||||
def __init__(self, client: AsyncClient, token: str, avatars: dict):
|
||||
super().__init__(deepcopy(avatars))
|
||||
self._get_header = {
|
||||
'X-Api-Key': token
|
||||
}
|
||||
self._post_header = {
|
||||
'X-Api-Key': token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
self._http_client = client
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
async def create_video(self, text: str, avatar: str):
|
||||
if not avatar:
|
||||
random_avatar_name = random.choice(list(self._avatars.keys()))
|
||||
avatar = self._avatars[random_avatar_name]["id"]
|
||||
#["id"]
|
||||
else:
|
||||
avatar = self._avatars[avatar]["id"]
|
||||
|
||||
create_video_url = f'https://api.heygen.com/v2/template/{avatar}/generate'
|
||||
data = {
|
||||
"test": False,
|
||||
"caption": False,
|
||||
"title": "video_title",
|
||||
"variables": {
|
||||
"script_here": {
|
||||
"name": "script_here",
|
||||
"type": "text",
|
||||
"properties": {
|
||||
"content": text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response = await self._http_client.post(create_video_url, headers=self._post_header, json=data)
|
||||
self._logger.info(response.status_code)
|
||||
self._logger.info(response.json())
|
||||
video_id = response.json()["data"]["video_id"]
|
||||
|
||||
return Task(
|
||||
result=video_id,
|
||||
status=TaskStatus.STARTED,
|
||||
)
|
||||
|
||||
|
||||
async def pool_status(self, video_id: str) -> Task:
|
||||
response = await self._http_client.get(self._GET_VIDEO_URL, headers=self._get_header, params={
|
||||
'video_id': video_id
|
||||
})
|
||||
response_data = response.json()
|
||||
|
||||
status = response_data["data"]["status"]
|
||||
error = response_data["data"]["error"]
|
||||
|
||||
if status != "completed" and error is None:
|
||||
self._logger.info(f"Status: {status}")
|
||||
return Task(
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
result=video_id
|
||||
)
|
||||
|
||||
if error:
|
||||
self._logger.error('Video creation failed.')
|
||||
return Task(
|
||||
status=TaskStatus.ERROR,
|
||||
result=response_data.get('url')
|
||||
)
|
||||
|
||||
url = response.json()['data']['video_url']
|
||||
self._logger.info(f'Successfully generated video: {url}')
|
||||
return Task(
|
||||
status=TaskStatus.COMPLETED,
|
||||
result=url
|
||||
)
|
||||
30
app/services/impl/third_parties/heygen/avatars.json
Normal file
30
app/services/impl/third_parties/heygen/avatars.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Matthew Noah": {
|
||||
"id": "5912afa7c77c47d3883af3d874047aaf",
|
||||
"avatar_gender": "male"
|
||||
},
|
||||
"Vera Cerise": {
|
||||
"id": "9e58d96a383e4568a7f1e49df549e0e4",
|
||||
"avatar_gender": "female"
|
||||
},
|
||||
"Edward Tony": {
|
||||
"id": "d2cdd9c0379a4d06ae2afb6e5039bd0c",
|
||||
"avatar_gender": "male"
|
||||
},
|
||||
"Tanya Molly": {
|
||||
"id": "045cb5dcd00042b3a1e4f3bc1c12176b",
|
||||
"avatar_gender": "female"
|
||||
},
|
||||
"Kayla Abbi": {
|
||||
"id": "1ae1e5396cc444bfad332155fdb7a934",
|
||||
"avatar_gender": "female"
|
||||
},
|
||||
"Jerome Ryan": {
|
||||
"id": "0ee6aa7cc1084063a630ae514fccaa31",
|
||||
"avatar_gender": "male"
|
||||
},
|
||||
"Tyler Christopher": {
|
||||
"id": "5772cff935844516ad7eeff21f839e43",
|
||||
"avatar_gender": "male"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user