Commit 25/09/2025
This commit is contained in:
315
src/Main.kt
315
src/Main.kt
@@ -2,17 +2,20 @@ import audio.AudioPlayer
|
||||
import barix.BarixConnection
|
||||
import barix.TCP_Barix_Command_Server
|
||||
import codes.Somecodes.Companion.Get_ANN_ID
|
||||
import codes.Somecodes.Companion.IsAlphabethic
|
||||
import codes.Somecodes.Companion.IsNumber
|
||||
import codes.Somecodes.Companion.ValidFile
|
||||
import codes.Somecodes.Companion.ValidString
|
||||
import codes.Somecodes.Companion.dateformat1
|
||||
import codes.Somecodes.Companion.timeformat2
|
||||
import com.sun.jna.Platform
|
||||
import content.Category
|
||||
import content.ContentCache
|
||||
import content.Language
|
||||
import content.ScheduleDay
|
||||
import content.VoiceType
|
||||
import database.MariaDB
|
||||
import database.Messagebank
|
||||
import database.QueuePaging
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -24,18 +27,19 @@ import web.WebApp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.util.function.Consumer
|
||||
import kotlin.concurrent.fixedRateTimer
|
||||
|
||||
|
||||
fun main() {
|
||||
val version = "0.0.1 (23/09/2025)"
|
||||
val StreamerOutputs : MutableMap<String, BarixConnection> = HashMap()
|
||||
val StreamerOutputs: MutableMap<String, BarixConnection> = HashMap()
|
||||
|
||||
if (Platform.isWindows()) {
|
||||
// supaya OSHI bisa mendapatkan CPU usage di Windows seperti di Task Manager
|
||||
GlobalConfig.set(GlobalConfig.OSHI_OS_WINDOWS_CPU_UTILITY, true)
|
||||
}
|
||||
Logger.info{"Starting AAS New Generation version $version"}
|
||||
Logger.info { "Starting AAS New Generation version $version" }
|
||||
val audioPlayer = AudioPlayer(44100) // 44100 Hz sampling rate
|
||||
audioPlayer.InitAudio(1)
|
||||
val content = ContentCache()
|
||||
@@ -49,19 +53,19 @@ fun main() {
|
||||
* @return true jika semua valid, false jika ada yang tidak valid
|
||||
*/
|
||||
fun AllBroadcastZonesValid(bz: List<String>): Boolean {
|
||||
if (bz.isNotEmpty()){
|
||||
if (bz.isNotEmpty()) {
|
||||
val validchannels = bz
|
||||
// check apakah tiap zone ada di database broadcast zones
|
||||
.filter {
|
||||
z1 -> db.BroadcastZoneList.find { z2 -> z2.SoundChannel==z1 } != null
|
||||
.filter { z1 ->
|
||||
db.BroadcastZoneList.find { z2 -> z2.SoundChannel == z1 } != null
|
||||
}
|
||||
// check apakah tiap zone ada di SoundChannelList dan Online
|
||||
.filter {
|
||||
z3 -> StreamerOutputs.any { sc -> sc.value.channel==z3 && sc.value.isOnline() }
|
||||
.filter { z3 ->
|
||||
StreamerOutputs.any { sc -> sc.value.channel == z3 && sc.value.isOnline() }
|
||||
}
|
||||
|
||||
// kalau jumlah valid channel sama dengan jumlah broadcast zone, berarti semua valid
|
||||
return validchannels.size==bz.size
|
||||
return validchannels.size == bz.size
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -72,9 +76,9 @@ fun main() {
|
||||
* @return true jika semua idle, false jika ada yang tidak idle
|
||||
*/
|
||||
fun AllBroadcastZoneIdle(bz: List<String>): Boolean {
|
||||
if (bz.isNotEmpty()){
|
||||
return bz.all {
|
||||
z1 -> StreamerOutputs.any { sc -> sc.value.channel==z1 && sc.value.isIdle() }
|
||||
if (bz.isNotEmpty()) {
|
||||
return bz.all { z1 ->
|
||||
StreamerOutputs.any { sc -> sc.value.channel == z1 && sc.value.isIdle() }
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -96,88 +100,258 @@ fun main() {
|
||||
/**
|
||||
* Fungsi untuk ambil messagebank berdasarkan ANN_ID, diurutkan berdasarkan urutan bahasa di urutan_bahasa
|
||||
* @param id ANN_ID dari messagebank
|
||||
* @param languages List of language yang diinginkan, default urutan_bahasa
|
||||
* @return List of Messagebank
|
||||
*/
|
||||
fun Get_MessageBank_by_id(id: Int) : ArrayList<Messagebank>{
|
||||
fun Get_MessageBank_by_id(id: Int, languages: List<String> = urutan_bahasa): ArrayList<Messagebank> {
|
||||
val mb_list = ArrayList<Messagebank>()
|
||||
urutan_bahasa.forEach {
|
||||
lang -> db.MessagebankList.find { mb -> mb.ANN_ID==id.toUInt() && mb.Language==lang && mb.Voice_Type==selected_voice }?.let {
|
||||
mb_list.add(it)
|
||||
}
|
||||
languages.forEach { lang ->
|
||||
db.MessagebankList.find { mb -> mb.ANN_ID == id.toUInt() && mb.Language == lang && mb.Voice_Type == selected_voice }
|
||||
?.let {
|
||||
mb_list.add(it)
|
||||
}
|
||||
}
|
||||
return mb_list
|
||||
}
|
||||
|
||||
/**
|
||||
* Find soundbank files from messagebank tags, filtered by VoiceType and Language
|
||||
* @param mb Messagebank object
|
||||
* @param variables Map of variables to replace in tags.
|
||||
* @param cbOK Callback function if success, returns List of soundbank file names
|
||||
* @param cbFail Callback function if fail, returns error message
|
||||
*/
|
||||
fun Get_Soundbank_Files(
|
||||
mb: Messagebank,
|
||||
variables: Map<String, String>,
|
||||
cbOK: Consumer<List<String>>,
|
||||
cbFail: Consumer<String>
|
||||
) {
|
||||
val tags = mb.Message_TAGS.split(" ")
|
||||
if (tags.isEmpty()) {
|
||||
cbFail.accept("No tags found in messagebank id ${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
// dapatkan soundbank array berdasarkan VoiceType dan Language
|
||||
val sb = db.SoundbankList
|
||||
.filter { it.VoiceType == mb.Voice_Type }
|
||||
.filter { it.Language == mb.Language }
|
||||
if (sb.isEmpty()) {
|
||||
cbFail.accept("No soundbank found for voice type ${mb.Voice_Type} and language ${mb.Language}")
|
||||
return
|
||||
}
|
||||
|
||||
val files = mutableListOf<String>()
|
||||
|
||||
tags.forEach { tag ->
|
||||
val _tag = tag.trim()
|
||||
when (_tag) {
|
||||
"[AIRPLANE_NAME]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[FLIGHT_NUMBER]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[PLATNOMOR]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[CITY]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[PLACES]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[ETAD]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[SHALAT]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[BCB]" -> {
|
||||
|
||||
}
|
||||
|
||||
"[GATENUMBER]" -> {
|
||||
// gate number bisa angka saja, misalnya 1,2,3
|
||||
// atau huruf dan angka, misalnya A1, B2, C3, 1A, 2B, 3C
|
||||
val value = variables["GATENUMBER"].orEmpty()
|
||||
if (ValidString(value)) {
|
||||
val values = value.split(",").map { it.trim() }.filter { ValidString(it) }
|
||||
if (values.isNotEmpty()){
|
||||
values.forEach {
|
||||
if (IsNumber(it)){
|
||||
// gate number hanya angka
|
||||
} else {
|
||||
// gate number gabungan huruf dan angka
|
||||
val regex = Regex("([A-Z])?(\\d+)([A-Z])?")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
cbFail.accept("GATENUMBER variable is empty after split for tag $_tag in messagebank id ${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
cbFail.accept("GATENUMBER variable is missing or empty for tag $_tag in messagebank id ${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
"[REASON]" -> {
|
||||
val reason = sb.firstOrNull { it.Category == Category.Reason.name && it.TAG == _tag }
|
||||
if (reason != null) {
|
||||
if (ValidFile(reason.Path)) {
|
||||
files.add(reason.Path)
|
||||
} else {
|
||||
cbFail.accept("Invalid soundbank file ${reason.Path} for tag=$_tag voicetype=${mb.Voice_Type} language=${mb.Language} ANN_ID=${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
cbFail.accept("No Reason found for tag=$_tag voicetype=${mb.Voice_Type} language=${mb.Language} ANN_ID=${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
"[PROCEDURE]" -> {
|
||||
val procedure = sb.firstOrNull { it.Category == Category.Procedure.name && it.TAG == _tag }
|
||||
if (procedure != null) {
|
||||
if (ValidFile(procedure.Path)) {
|
||||
files.add(procedure.Path)
|
||||
} else {
|
||||
cbFail.accept("Invalid soundbank file ${procedure.Path} for tag=$_tag voicetype=${mb.Voice_Type} language=${mb.Language} ANN_ID=${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
cbFail.accept("No Procedure found for tag=$_tag voicetype=${mb.Voice_Type} language=${mb.Language} ANN_ID=${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Phrase
|
||||
val phrase = sb.firstOrNull { it.Category == Category.Phrase.name && it.TAG == _tag }
|
||||
if (phrase != null) {
|
||||
if (ValidFile(phrase.Path)) {
|
||||
files.add(phrase.Path)
|
||||
} else {
|
||||
cbFail.accept("Invalid soundbank file ${phrase.Path} for tag=$_tag voicetype=${mb.Voice_Type} language=${mb.Language} ANN_ID=${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
cbFail.accept("No Phrase found for tag=$_tag voicetype=${mb.Voice_Type} language=${mb.Language} ANN_ID=${mb.ANN_ID}")
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
jobloop@ while (isActive) {
|
||||
delay(1000)
|
||||
|
||||
// prioritas 1 , habisin queue paging
|
||||
for(it in db.Read_Queue_Paging()){
|
||||
if (it.BroadcastZones.isNotBlank()){
|
||||
val zz = it.BroadcastZones.split(";")
|
||||
if (AllBroadcastZonesValid(zz)){
|
||||
if (AllBroadcastZoneIdle(zz)){
|
||||
if (it.Source=="PAGING"){
|
||||
for (qp in db.Read_Queue_Paging()) {
|
||||
if (qp.BroadcastZones.isNotBlank()) {
|
||||
val zz = qp.BroadcastZones.split(";")
|
||||
if (AllBroadcastZonesValid(zz)) {
|
||||
if (AllBroadcastZoneIdle(zz)) {
|
||||
if (qp.Source == "PAGING") {
|
||||
// nama file ada di Message
|
||||
if (ValidFile(it.Message)){
|
||||
val afi = audioPlayer.LoadAudioFile(it.Message)
|
||||
zz.forEach {
|
||||
z1 -> StreamerOutputs.values.find { it.channel==z1 }?.SendData(afi.bytes, {db.Add_Log("AAS", it) }, {db.Add_Log("AAS", it)} )
|
||||
if (ValidFile(qp.Message)) {
|
||||
val afi = audioPlayer.LoadAudioFile(qp.Message)
|
||||
zz.forEach { z1 ->
|
||||
StreamerOutputs.values.find { it.channel == z1 }
|
||||
?.SendData(afi.bytes, { db.Add_Log("AAS", it) }, { db.Add_Log("AAS", it) })
|
||||
}
|
||||
val logmessage = "Broadcast started PAGING with Filename '${it.Message}' to zones: ${it.BroadcastZones}"
|
||||
Logger.info { logmessage}
|
||||
val logmessage =
|
||||
"Broadcast started PAGING with Filename '${qp.Message}' to zones: ${qp.BroadcastZones}"
|
||||
Logger.info { logmessage }
|
||||
db.Add_Log("AAS", logmessage)
|
||||
db.Delete_Queue_Paging_by_index(it.index)
|
||||
db.Delete_Queue_Paging_by_index(qp.index)
|
||||
|
||||
continue@jobloop
|
||||
} else {
|
||||
// file tidak valid, delete from queue paging
|
||||
db.Delete_Queue_Paging_by_index(it.index)
|
||||
db.Add_Log("AAS", "Cancelled paging message with index ${it.index} due to invalid audio file" )
|
||||
db.Delete_Queue_Paging_by_index(qp.index)
|
||||
db.Add_Log(
|
||||
"AAS",
|
||||
"Cancelled paging message with index ${qp.index} due to invalid audio file"
|
||||
)
|
||||
}
|
||||
} else if (it.Source=="SHALAT"){
|
||||
val ann_id = Get_ANN_ID(it.Message)
|
||||
if (ann_id>0){
|
||||
Get_MessageBank_by_id(ann_id).forEach {
|
||||
// cari tags nya, create content nya, broadcast ke semua zone
|
||||
} else if (qp.Source == "SHALAT") {
|
||||
val ann_id = Get_ANN_ID(qp.Message)
|
||||
if (ann_id > 0) {
|
||||
// shalat, ambil messagebank berdasarkan ann_id dengan bahasa Indonesia saja
|
||||
Get_MessageBank_by_id(ann_id, listOf(Language.INDONESIA.name)).let { mblist ->
|
||||
if (mblist.isNotEmpty()) {
|
||||
//TODO find soundbank
|
||||
} else {
|
||||
// tidak ada messagebank dengan ann_id ini, delete from queue paging
|
||||
db.Delete_Queue_Paging_by_index(qp.index)
|
||||
db.Add_Log(
|
||||
"AAS",
|
||||
"Cancelled Shalat message with index ${qp.index} due to ANN_ID $ann_id not found in Messagebank"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else{
|
||||
} else {
|
||||
// invalid ann_id, delete from queue paging
|
||||
db.Delete_Queue_Paging_by_index(it.index)
|
||||
db.Add_Log("AAS", "Cancelled Shalat message with index ${it.index} due to invalid ANN_ID" )
|
||||
db.Delete_Queue_Paging_by_index(qp.index)
|
||||
db.Add_Log(
|
||||
"AAS",
|
||||
"Cancelled Shalat message with index ${qp.index} due to invalid ANN_ID"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ada broadcast zone yang tidak valid, delete from queue paging
|
||||
db.Delete_Queue_Paging_by_index(it.index)
|
||||
db.Add_Log("AAS", "Cancelled paging message with index ${it.index} due to invalid broadcast zone" )
|
||||
db.Delete_Queue_Paging_by_index(qp.index)
|
||||
db.Add_Log(
|
||||
"AAS",
|
||||
"Cancelled paging message with index ${qp.index} due to invalid broadcast zone"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// invalid broadcast zone, delete from queue paging
|
||||
db.Delete_Queue_Paging_by_index(it.index)
|
||||
db.Add_Log("AAS", "Cancelled paging message with index ${it.index} due to empty broadcast zone")
|
||||
db.Delete_Queue_Paging_by_index(qp.index)
|
||||
db.Add_Log("AAS", "Cancelled paging message with index ${qp.index} due to empty broadcast zone")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// prioritas 2, habisin queue table
|
||||
db.Read_Queue_Table().forEach {
|
||||
if (it.BroadcastZones.isNotEmpty()){
|
||||
if (it.BroadcastZones.isNotEmpty()) {
|
||||
val zz = it.BroadcastZones.split(";")
|
||||
if (AllBroadcastZonesValid(zz)){
|
||||
if (AllBroadcastZoneIdle(zz)){
|
||||
if (it.Type=="SOUNDBANK"){
|
||||
if (AllBroadcastZonesValid(zz)) {
|
||||
if (AllBroadcastZoneIdle(zz)) {
|
||||
if (it.Type == "SOUNDBANK") {
|
||||
|
||||
} else if (it.Type=="TIMER"){
|
||||
} else if (it.Type == "TIMER") {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
// ada broadcast zone yang tidak valid, delete from queue table
|
||||
db.Delete_Queue_Table_by_index(it.index)
|
||||
db.Add_Log("AAS", "Cancelled table message with index ${it.index} due to invalid broadcast zone")
|
||||
db.Add_Log(
|
||||
"AAS",
|
||||
"Cancelled table message with index ${it.index} due to invalid broadcast zone"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// invalid broadcast zone, delete from queue table
|
||||
@@ -195,10 +369,10 @@ fun main() {
|
||||
delay(1000)
|
||||
val localtime = LocalTime.now()
|
||||
// detik harus 00
|
||||
if (localtime.second!=0) continue
|
||||
if (localtime.second != 0) continue
|
||||
val timestring = timeformat2.format(localtime)
|
||||
val sch = db.SchedulebankList.filter{
|
||||
it.Time==timestring && it.Enable
|
||||
val sch = db.SchedulebankList.filter {
|
||||
it.Time == timestring && it.Enable
|
||||
}
|
||||
// tidak ada schedule dengan time sekarang dan enable=true
|
||||
if (sch.isEmpty()) continue
|
||||
@@ -207,15 +381,15 @@ fun main() {
|
||||
val ddmmyyyy = dateformat1.format(localdate)
|
||||
// check special date dulu
|
||||
val specialdate = sch.find {
|
||||
it.Day==ddmmyyyy
|
||||
it.Day == ddmmyyyy
|
||||
}
|
||||
if (specialdate!=null) {
|
||||
if (specialdate != null) {
|
||||
// TODO Masukin ke queue table sebagai schedule special date
|
||||
|
||||
}
|
||||
// cek weekly schedule
|
||||
val weekly = sch.find {
|
||||
it.Day == when(localdate.dayOfWeek){
|
||||
it.Day == when (localdate.dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> ScheduleDay.Monday.name
|
||||
DayOfWeek.TUESDAY -> ScheduleDay.Tuesday.name
|
||||
DayOfWeek.WEDNESDAY -> ScheduleDay.Wednesday.name
|
||||
@@ -225,7 +399,7 @@ fun main() {
|
||||
DayOfWeek.SUNDAY -> ScheduleDay.Sunday.name
|
||||
}
|
||||
}
|
||||
if (weekly!=null) {
|
||||
if (weekly != null) {
|
||||
// TODO Masukin ke queue table sebagai schedule weekly
|
||||
|
||||
}
|
||||
@@ -233,13 +407,12 @@ fun main() {
|
||||
val daily = sch.find {
|
||||
it.Day == ScheduleDay.Everyday.name
|
||||
}
|
||||
if (daily!=null) {
|
||||
if (daily != null) {
|
||||
// TODO Masukin ke queue table sebagai schedule daily
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,16 +425,16 @@ fun main() {
|
||||
)
|
||||
web.Start()
|
||||
|
||||
val barixserver = TCP_Barix_Command_Server ()
|
||||
val barixserver = TCP_Barix_Command_Server()
|
||||
barixserver.StartTcpServer { cmd ->
|
||||
Logger.info{cmd}
|
||||
Logger.info { cmd }
|
||||
val _streamer = StreamerOutputs[cmd.ipaddress]
|
||||
val _sc = db.SoundChannelList.find { it.ip == cmd.ipaddress }
|
||||
if (_streamer==null){
|
||||
if (_streamer == null) {
|
||||
// belum create BarixConnection untuk ipaddress ini
|
||||
Logger.info{"New Streamer Output connection from ${cmd.ipaddress}"}
|
||||
if (_sc!=null){
|
||||
val _bc = BarixConnection(_sc.index,_sc.channel,cmd.ipaddress)
|
||||
Logger.info { "New Streamer Output connection from ${cmd.ipaddress}" }
|
||||
if (_sc != null) {
|
||||
val _bc = BarixConnection(_sc.index, _sc.channel, cmd.ipaddress)
|
||||
_bc.vu = cmd.vu
|
||||
_bc.bufferRemain = cmd.buffremain
|
||||
_bc.statusData = cmd.statusdata
|
||||
@@ -270,7 +443,7 @@ fun main() {
|
||||
|
||||
} else {
|
||||
// sudah ada, update data
|
||||
if (_sc !=null && _sc.channel != _streamer.channel) {
|
||||
if (_sc != null && _sc.channel != _streamer.channel) {
|
||||
_streamer.channel = _sc.channel
|
||||
}
|
||||
_streamer.vu = cmd.vu
|
||||
@@ -280,7 +453,7 @@ fun main() {
|
||||
|
||||
}
|
||||
|
||||
val onlinechecker = fixedRateTimer(name="onlinecheck", initialDelay = 1000, period = 1000) {
|
||||
val onlinechecker = fixedRateTimer(name = "onlinecheck", initialDelay = 1000, period = 1000) {
|
||||
// cek setiap 1 detik, decrement online counter semua BarixConnection
|
||||
StreamerOutputs.values.forEach {
|
||||
it.decrementOnlineCounter()
|
||||
@@ -289,14 +462,14 @@ fun main() {
|
||||
|
||||
// shutdown hook
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
Logger.info{"Shutdown hook called, stopping services..."}
|
||||
Logger.info { "Shutdown hook called, stopping services..." }
|
||||
barixserver.StopTcpCommand()
|
||||
onlinechecker.cancel()
|
||||
web.Stop()
|
||||
audioPlayer.Close()
|
||||
db.close()
|
||||
Logger.info{"All services stopped, exiting application."}
|
||||
} )
|
||||
Logger.info { "All services stopped, exiting application." }
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ class Somecodes {
|
||||
// regex for getting ann_id from Message, which is the number inside []
|
||||
private val ann_id_regex = Regex("\\[(\\d+)]")
|
||||
|
||||
/**
|
||||
* Check if a string is a valid number.
|
||||
*/
|
||||
fun IsNumber(value: String) : Boolean {
|
||||
return value.toIntOrNull() != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is alphabetic (contains only letters).
|
||||
*/
|
||||
fun IsAlphabethic(value: String) : Boolean {
|
||||
return value.all { it.isLetter() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ANN ID from a message string.
|
||||
* The ANN ID is expected to be a number enclosed in square brackets (e.g., "[123]").
|
||||
|
||||
@@ -11,5 +11,7 @@ enum class Category(name: String) {
|
||||
PlatNomor("PlatNomor"),
|
||||
Shalat("Shalat"),
|
||||
Year("Year"),
|
||||
Birthday("Birthday");
|
||||
Birthday("Birthday"),
|
||||
Reason("Reason"),
|
||||
Procedure("Procedure");
|
||||
}
|
||||
Reference in New Issue
Block a user