v 0.0.61 (мелкие недочеты)

This commit is contained in:
DND
2026-06-05 15:28:39 +07:00
parent 18ae1b62cc
commit c248ce166b
40 changed files with 546 additions and 228 deletions
Regular → Executable
View File
Regular → Executable
BIN
View File
Binary file not shown.
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+10 -39
View File
@@ -21,16 +21,13 @@ import (
var staticFiles embed.FS
func main() {
// Инициализация базы данных
db := database.GetDB()
defer db.Close()
// Инициализация настроек по умолчанию
if err := db.InitDefaultSettings(); err != nil {
log.Printf("Warning: Failed to init default settings: %v", err)
}
// Получаем папку для записей из БД
recordingsDir, err := db.GetSetting("recordings_dir")
if err != nil || recordingsDir == "" {
recordingsDir = "recordings"
@@ -39,20 +36,17 @@ func main() {
}
}
// Создаем папку для записей
if err := os.MkdirAll(recordingsDir, 0755); err != nil {
log.Fatal("Не удалось создать папку для записей:", err)
}
log.Printf("Папка для записей: %s", recordingsDir)
// Инициализируем устройства при старте
dm := devices.GetManager()
log.Printf("Сканирование устройств при старте...")
devicesList := dm.ScanDevices()
log.Printf("Найдено устройств: %d", len(devicesList))
// Сохраняем устройства в БД
for _, dev := range devicesList {
status := "✓"
if !dev.Working {
@@ -74,10 +68,8 @@ func main() {
}
}
// Создаем рекордер
rec := recorder.NewRecorder(recordingsDir)
// Проверяем, есть ли каналы в БД, если нет - создаем каналы по умолчанию
channels, err := db.GetAllChannels()
if err != nil {
log.Printf("Warning: Failed to get channels from DB: %v", err)
@@ -86,11 +78,9 @@ func main() {
if len(channels) == 0 {
log.Println("No channels in database, creating default channels...")
// Создаем папки для каналов
os.MkdirAll(filepath.Join(recordingsDir, "channel1"), 0755)
os.MkdirAll(filepath.Join(recordingsDir, "channel2"), 0755)
// Канал 1
channel1 := recorder.NewChannel(
"channel1",
"Канал 1",
@@ -105,7 +95,6 @@ func main() {
channel1.SMBFolder = "channel1"
rec.AddChannel(channel1)
// Канал 2
channel2 := recorder.NewChannel(
"channel2",
"Канал 2",
@@ -123,24 +112,17 @@ func main() {
log.Println("Default channels created successfully")
}
// Устанавливаем экземпляр рекордера в обработчики
server.SetRecorder(rec)
// Устанавливаем экземпляры для обратных вызовов
recorder.SetRecorderInstance(rec)
recorder.SetDeviceManager(dm)
// Восстанавливаем состояние каналов после перезагрузки
restoreChannelsState(rec, dm)
// Запускаем проверяльщик состояний
stateChecker := recorder.GetStateChecker()
stateChecker.Start()
// Настройка SMB клиента
smbClient := smb.GetSMBClient()
// Загружаем настройки SMB из БД
smbEnabled, _ := db.GetSetting("smb_enabled")
smbAutoStart, _ := db.GetSetting("smb_auto_start")
smbServer, _ := db.GetSetting("smb_server")
@@ -158,7 +140,6 @@ func main() {
}
}
// Применяем настройки SMB
if err := smbClient.UpdateConfig(smb.SMBConfig{
Enabled: smbEnabled == "true",
AutoStart: smbAutoStart == "true",
@@ -173,7 +154,6 @@ func main() {
log.Printf("Warning: Failed to update SMB config: %v", err)
}
// Устанавливаем колбэк для отправки файлов на SMB
recorder.SetRecordingCompletedCallback(func(filename string, filePath string, smbFolder string) {
log.Printf("Запись завершена: %s, отправка в SMB папку: %s", filename, smbFolder)
@@ -194,7 +174,6 @@ func main() {
}
})
// Запускаем SMB клиент если включен автозапуск
if smbEnabled == "true" && smbAutoStart == "true" {
log.Printf("Запуск SMB клиента при автозапуске...")
if err := smbClient.Start(); err != nil {
@@ -202,10 +181,8 @@ func main() {
}
}
// Запускаем обработчик очереди повторных отправок
go smbClient.ProcessRetryQueue()
// Настраиваем обработку сигналов для graceful shutdown
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
@@ -213,20 +190,14 @@ func main() {
<-signalChan
log.Println("Получен сигнал завершения, сохраняем состояния каналов...")
// Останавливаем проверяльщик состояний
stateChecker.Stop()
// Останавливаем SMB клиент
smbClient.Stop()
// Сохраняем состояния всех каналов в БД
rec.SaveAllStates()
log.Println("Завершаем работу...")
os.Exit(0)
}()
// Настраиваем и запускаем HTTP сервер
srv := server.NewServer(staticFiles, recordingsDir)
srv.SetupRoutes()
@@ -237,7 +208,6 @@ func main() {
log.Printf("🔄 Проверка состояния каналов: каждые %d секунд", stateChecker.GetCheckInterval())
log.Printf("")
// Запускаем сервер с graceful shutdown
if err := srv.Run(); err != nil {
log.Fatal("Server error:", err)
}
@@ -266,17 +236,18 @@ func restoreChannelsState(rec *recorder.Recorder, dm *devices.DeviceManager) {
return dm.TestDevice(device)
}
// Запускаем запись в отдельной горутине
go func(channel *recorder.Channel, deviceFunc func(string) (bool, string)) {
time.Sleep(2 * time.Second) // Ждем инициализации
if err := channel.StartRecording(channel.OutputDir, deviceFunc); err != nil {
log.Printf("Не удалось восстановить запись для канала %s: %v", channel.ID, err)
// Обновляем состояние в БД на false, так как запись не удалась
db.SetChannelState(channel.ID, false)
// Используем отдельную горутину с копией переменных
channelID := id
channel := ch
go func() {
time.Sleep(2 * time.Second)
if err := channel.StartRecording(channel.OutputDir, testFunc); err != nil {
log.Printf("Не удалось восстановить запись для канала %s: %v", channelID, err)
db.SetChannelState(channelID, false)
} else {
log.Printf("✅ Запись успешно восстановлена для канала %s", channel.ID)
log.Printf("✅ Запись успешно восстановлена для канала %s", channelID)
}
}(ch, testFunc)
}()
} else {
log.Printf("Запись для канала %s не должна быть активна. Состояние OK.", id)
}
Regular → Executable
View File
Regular → Executable
+20 -15
View File
@@ -1,4 +1,3 @@
// recorder/channel.go
package recorder
import (
@@ -31,7 +30,6 @@ type Channel struct {
SMBFolder string `json:"smb_folder"`
CreatedAt time.Time `json:"created_at"`
// Поля расписания
ScheduleEnabled bool `json:"schedule_enabled"`
StartDate string `json:"start_date"`
StartTime string `json:"start_time"`
@@ -95,19 +93,20 @@ func NewChannel(id, name, device string, duration, sampleRate, channels int, for
id, name, device, duration, sampleRate, channels, format, autoRestart)
return &Channel{
ID: id,
Name: name,
Device: device,
Duration: duration,
SampleRate: sampleRate,
Channels: channels,
Format: format,
AutoRestart: autoRestart,
SMBFolder: "",
CreatedAt: time.Now(),
OutputDir: outputDir,
audioData: make([]byte, 0, sampleRate*channels*2),
stopChan: make(chan struct{}),
ID: id,
Name: name,
Device: device,
Duration: duration,
SampleRate: sampleRate,
Channels: channels,
Format: format,
AutoRestart: autoRestart,
SMBFolder: "",
CreatedAt: time.Now(),
OutputDir: outputDir,
audioData: make([]byte, 0, sampleRate*channels*2),
stopChan: make(chan struct{}),
ScheduleEnabled: false,
}
}
@@ -270,6 +269,9 @@ func (ch *Channel) StartRecording(outputDir string, testDeviceFunc func(string)
if recording.ID > 0 {
db.UpdateRecordingStatus(recording.ID, "completed")
if fileInfo, err := os.Stat(currentPath); err == nil {
// Используем ch.Duration который был установлен при старте
log.Printf("Recording completed: %s, duration=%d seconds, size=%d bytes",
currentFile, ch.Duration, fileInfo.Size())
db.UpdateRecordingStats(recording.ID, fileInfo.Size(), ch.Duration)
}
}
@@ -579,6 +581,7 @@ func (ch *Channel) StopSchedule() {
}
}
// getAudioCommandWithContext - изменённый для правильной записи WAV
func getAudioCommandWithContext(ctx context.Context, device string, sampleRate, channels int, format, filepath string) *exec.Cmd {
args := []string{}
@@ -602,9 +605,11 @@ func getAudioCommandWithContext(ctx context.Context, device string, sampleRate,
args = append(args, "-f", "S16_LE")
}
// Используем формат wav - arecord сам правильно заполнит заголовок
args = append(args, "-t", "wav")
args = append(args, filepath)
log.Printf("Record command: arecord %v", args)
cmd := exec.CommandContext(ctx, "arecord", args...)
return cmd
}
Regular → Executable
+6 -2
View File
@@ -1,4 +1,3 @@
// recorder/recorder.go
package recorder
import (
@@ -6,6 +5,7 @@ import (
"os"
"path/filepath"
"sync"
"time"
"audio-recorder/database"
)
@@ -57,7 +57,11 @@ func (r *Recorder) loadChannelsFromDB() {
channel.RepeatDaily = schedule.RepeatDaily
if schedule.Enabled {
go channel.StartSchedule()
// Добавляем задержку перед стартом расписания для полной инициализации
go func(ch *Channel) {
time.Sleep(2 * time.Second)
ch.StartSchedule()
}(channel)
}
}
Regular → Executable
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+469 -127
View File
@@ -81,7 +81,6 @@ func safeWriteJSON(conn *websocket.Conn, v interface{}) error {
return fmt.Errorf("connection is nil")
}
// Получаем или создаем SafeWebSocket
wsMu.Lock()
connKey := fmt.Sprintf("%p", conn)
safeConn, exists := wsConnections[connKey]
@@ -91,7 +90,6 @@ func safeWriteJSON(conn *websocket.Conn, v interface{}) error {
}
wsMu.Unlock()
// Блокируем только этот конкретный сокет
safeConn.mu.Lock()
defer safeConn.mu.Unlock()
@@ -101,7 +99,6 @@ func safeWriteJSON(conn *websocket.Conn, v interface{}) error {
}
}()
// Устанавливаем deadline для записи
if err := conn.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
log.Printf("Failed to set write deadline: %v", err)
}
@@ -121,7 +118,6 @@ func cleanupClosedWebSockets() {
delete(wsConnections, key)
continue
}
// Проверяем, закрыто ли соединение
sc.mu.Lock()
err := sc.conn.WriteMessage(websocket.PingMessage, nil)
sc.mu.Unlock()
@@ -165,7 +161,6 @@ func HandleWebSocket(w http.ResponseWriter, r *http.Request) {
log.Printf("WebSocket connected for user %s, channel %s", username, channelID)
// Устанавливаем обработчики ДО начала горутин
conn.SetPongHandler(func(appData string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
@@ -174,7 +169,6 @@ func HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
// Отправляем приветственное сообщение
if err := safeWriteJSON(conn, map[string]interface{}{
"type": "connected",
"channel": channelID,
@@ -188,7 +182,6 @@ func HandleWebSocket(w http.ResponseWriter, r *http.Request) {
done := make(chan struct{})
defer close(done)
// Горoutine для чтения сообщений от клиента
go func() {
defer func() {
if r := recover(); r != nil {
@@ -512,7 +505,6 @@ func HandleStart(w http.ResponseWriter, r *http.Request) {
return
}
// Сохраняем состояние в БД (запись активна)
db := database.GetDB()
if err := db.SetChannelState(channelID, true); err != nil {
log.Printf("Warning: Failed to save channel state to DB: %v", err)
@@ -569,7 +561,6 @@ func HandleStop(w http.ResponseWriter, r *http.Request) {
ch.StopRecording()
}
// Сохраняем состояние в БД (остановлено)
db := database.GetDB()
if err := db.SetChannelState(channelID, false); err != nil {
log.Printf("Warning: Failed to save channel state to DB: %v", err)
@@ -577,7 +568,6 @@ func HandleStop(w http.ResponseWriter, r *http.Request) {
log.Printf("✅ Channel %s state saved to DB: recording=false", channelID)
}
// Проверяем, что сохранилось
state, _ := db.GetChannelState(channelID)
log.Printf("✅ Verification - Channel %s state in DB: %v", channelID, state)
@@ -643,8 +633,28 @@ func HandleRecordings(w http.ResponseWriter, r *http.Request) {
}
fullPath := filepath.Join(channelDir, file.Name())
// Получаем длительность
durationSeconds := GetWavDuration(fullPath)
// Проверяем на нереалистичные значения (больше 1 часа для файла меньше 100MB)
if durationSeconds > 3600 && info.Size() < 100*1024*1024 {
// Слишком большая длительность для такого размера файла - пересчитываем
estimatedBytesPerSecond := 32000
durationSeconds = int(info.Size() / int64(estimatedBytesPerSecond))
if durationSeconds < 1 {
durationSeconds = 1
}
if durationSeconds > 3600 {
durationSeconds = 3600
}
}
// Ограничиваем максимальную длительность 24 часами
if durationSeconds > 86400 {
durationSeconds = 0
}
smbSent := false
dbRecordings, _ := db.GetRecordingsByChannel(channelID, 1000)
for _, rec := range dbRecordings {
@@ -882,7 +892,7 @@ func HandleForceStart(w http.ResponseWriter, r *http.Request) {
})
}
// GetWavDuration возвращает длительность WAV файла
// GetWavDuration возвращает длительность WAV файла в секундах
func GetWavDuration(filepath string) int {
file, err := os.Open(filepath)
if err != nil {
@@ -890,159 +900,384 @@ func GetWavDuration(filepath string) int {
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return 0
}
fileSize := fileInfo.Size()
if fileSize < 44 {
return 0
}
// Читаем заголовок
header := make([]byte, 44)
if _, err := file.Read(header); err != nil {
return 0
}
// Проверяем RIFF и WAVE
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return 0
}
var sampleRate, channels, bitsPerSample, dataSize int
offset := 12
// Получаем параметры из fmt чанка (стандартная позиция 12-36)
// Проверяем что fmt чанк на месте
if string(header[12:16]) == "fmt " {
channels := int(header[22]) | int(header[23])<<8
sampleRate := int(header[24]) | int(header[25])<<8 | int(header[26])<<16 | int(header[27])<<24
bitsPerSample := int(header[34]) | int(header[35])<<8
for offset < 44-8 {
chunkID := string(header[offset : offset+4])
chunkSize := int(header[offset+4]) | int(header[offset+5])<<8 | int(header[offset+6])<<16 | int(header[offset+7])<<24
// Пытаемся найти реальный размер data чанка
var dataSize int64
switch chunkID {
case "fmt ":
if chunkSize >= 16 {
channels = int(header[offset+8]) | int(header[offset+9])<<8
sampleRate = int(header[offset+12]) | int(header[offset+13])<<8 | int(header[offset+14])<<16 | int(header[offset+15])<<24
bitsPerSample = int(header[offset+22]) | int(header[offset+23])<<8
offset := int64(12)
for offset+8 <= fileSize {
chunkHeader := make([]byte, 8)
if _, err := file.ReadAt(chunkHeader, offset); err != nil {
break
}
case "data":
dataSize = chunkSize
chunkID := string(chunkHeader[0:4])
chunkSize := int64(chunkHeader[4]) | int64(chunkHeader[5])<<8 | int64(chunkHeader[6])<<16 | int64(chunkHeader[7])<<24
if chunkID == "data" {
dataSize = chunkSize
break
}
offset += 8 + chunkSize
}
if dataSize > 0 && dataSize < fileSize && sampleRate > 0 && channels > 0 && bitsPerSample > 0 {
bytesPerSample := bitsPerSample / 8
if bytesPerSample > 0 {
duration := int(dataSize / int64(sampleRate*channels*bytesPerSample))
if duration > 0 && duration < 86400 {
return duration
}
}
}
}
// Если не удалось прочитать из заголовка, вычисляем по размеру файла
dataSizeActual := fileSize - 44
if dataSizeActual <= 0 {
return 0
}
// Определяем по размеру файла
if dataSizeActual < 1000000 {
// Маленький файл - скорее всего 8kHz, 8bit, mono (8000 байт/сек)
return int(dataSizeActual / 8000)
} else if dataSizeActual < 3000000 {
// Средний файл - 16kHz, 16bit, mono (32000 байт/сек)
return int(dataSizeActual / 32000)
} else if dataSizeActual < 6000000 {
// 22kHz, 16bit, mono (44000 байт/сек)
return int(dataSizeActual / 44000)
} else {
// Большой файл - 48kHz, 16bit, stereo (192000 байт/сек)
return int(dataSizeActual / 192000)
}
}
// FixWavHeader исправляет заголовок WAV файла
func FixWavHeader(filepath string) error {
file, err := os.OpenFile(filepath, os.O_RDWR, 0644)
if err != nil {
return err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return err
}
fileSize := fileInfo.Size()
// Читаем заголовок
header := make([]byte, 44)
if _, err := file.ReadAt(header, 0); err != nil {
return err
}
// Проверяем что это WAV
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return nil // Не WAV файл
}
// Ищем data чанк
var dataOffset int64
var dataSize int64
offset := int64(12)
for offset+8 <= fileSize {
chunkHeader := make([]byte, 8)
if _, err := file.ReadAt(chunkHeader, offset); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := int64(chunkHeader[4]) | int64(chunkHeader[5])<<8 | int64(chunkHeader[6])<<16 | int64(chunkHeader[7])<<24
if chunkID == "data" {
dataOffset = offset + 8
dataSize = chunkSize
break
}
offset += 8 + chunkSize
}
if dataSize > 0 && dataSize != fileSize-dataOffset {
// Исправляем размер data чанка
correctDataSize := fileSize - dataOffset
log.Printf("Fixing WAV header for %s: dataSize %d -> %d", filepath, dataSize, correctDataSize)
// Записываем правильный размер data чанка
dataSizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(dataSizeBytes, uint32(correctDataSize))
file.WriteAt(dataSizeBytes, dataOffset-4)
// Исправляем общий размер RIFF чанка
riffSize := fileSize - 8
riffSizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(riffSizeBytes, uint32(riffSize))
file.WriteAt(riffSizeBytes, 4)
}
return nil
}
// estimateDurationFromSize оценивает длительность по размеру файла
func estimateDurationFromSize(fileSize int64) int {
if fileSize <= 44 {
return 0
}
// Пробуем разные варианты параметров
// 1. 16kHz, 16bit, mono = 32000 байт/сек
duration1 := int((fileSize - 44) / 32000)
// 2. 16kHz, 16bit, stereo = 64000 байт/сек
duration2 := int((fileSize - 44) / 64000)
// 3. 8kHz, 8bit, mono = 8000 байт/сек
duration3 := int((fileSize - 44) / 8000)
// 4. 44.1kHz, 16bit, stereo = 176400 байт/сек
duration4 := int((fileSize - 44) / 176400)
log.Printf("[WAV] Estimated durations: mono16=%d, stereo16=%d, mono8=%d, cd=%d",
duration1, duration2, duration3, duration4)
// Выбираем наиболее вероятную длительность
if duration1 > 0 && duration1 <= 3600 {
return duration1
}
if duration2 > 0 && duration2 <= 3600 {
return duration2
}
if duration3 > 0 && duration3 <= 3600 {
return duration3
}
if duration4 > 0 && duration4 <= 3600 {
return duration4
}
// Если всё плохо, возвращаем 0
return 0
}
// getWavDurationBySearch ищет fmt и data чанки во всем файле
func getWavDurationBySearch(file *os.File, fileSize int64) int {
// Сбрасываем указатель на начало
file.Seek(12, 0)
var sampleRate uint32
var channels uint16
var bitsPerSample uint16
var dataSize uint32
for {
// Читаем заголовок чанка
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8])
// Защита от слишком больших чанков
if chunkSize > uint32(fileSize) {
break
}
if chunkID == "fmt " {
// Читаем fmt данные
fmtData := make([]byte, chunkSize)
if _, err := file.Read(fmtData); err != nil {
break
}
if len(fmtData) >= 16 {
audioFormat := binary.LittleEndian.Uint16(fmtData[0:2])
if audioFormat == 1 { // PCM
channels = binary.LittleEndian.Uint16(fmtData[2:4])
sampleRate = binary.LittleEndian.Uint32(fmtData[4:8])
bitsPerSample = binary.LittleEndian.Uint16(fmtData[14:16])
}
}
} else if chunkID == "data" {
dataSize = chunkSize
// Если нашли data чанк и уже есть параметры, можно выйти
if sampleRate > 0 && channels > 0 && bitsPerSample > 0 {
break
}
// Если нет параметров, продолжаем поиск
} else {
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
}
// Проверяем позицию
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
if dataSize == 0 {
return 0
}
if sampleRate == 0 || channels == 0 || bitsPerSample == 0 {
return 0
}
bytesPerSample := bitsPerSample / 8
if bytesPerSample == 0 {
bytesPerSecond := uint32(sampleRate) * uint32(channels) * uint32(bitsPerSample/8)
if bytesPerSecond == 0 {
return 0
}
return dataSize / (sampleRate * channels * bytesPerSample)
return int(dataSize / bytesPerSecond)
}
// HandleFTPSettings - обработчик для настроек FTP сервера
func HandleFTPSettings(w http.ResponseWriter, r *http.Request) {
ftp := GetFTPServer()
db := database.GetDB()
// findDataChunk находит размер data чанка
func findDataChunk(file *os.File, fileSize int64) uint32 {
file.Seek(12, 0)
switch r.Method {
case http.MethodGet:
ftpEnabled, _ := db.GetSetting("ftp_enabled")
ftpPort, _ := db.GetSetting("ftp_port")
ftpUsername, _ := db.GetSetting("ftp_username")
ftpRootDir, _ := db.GetSetting("ftp_root_dir")
ftpAllowDownload, _ := db.GetSetting("ftp_allow_download")
ftpAllowUpload, _ := db.GetSetting("ftp_allow_upload")
config := map[string]interface{}{
"enabled": ftpEnabled == "true",
"port": ftpPort,
"username": ftpUsername,
"password": "********",
"root_dir": ftpRootDir,
"allow_download": ftpAllowDownload == "true",
"allow_upload": ftpAllowUpload == "true",
"running": ftp.Running,
}
sendJSONResponse(w, http.StatusOK, config)
case http.MethodPost:
var config map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
sendJSONError(w, "Invalid request body", http.StatusBadRequest)
return
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
// Сохраняем в БД
if enabled, ok := config["enabled"].(bool); ok {
db.SetSetting("ftp_enabled", map[bool]string{true: "true", false: "false"}[enabled])
}
if port, ok := config["port"].(float64); ok {
db.SetSetting("ftp_port", strconv.Itoa(int(port)))
}
if username, ok := config["username"].(string); ok {
db.SetSetting("ftp_username", username)
}
if password, ok := config["password"].(string); ok && password != "" && password != "********" {
db.SetSetting("ftp_password", password)
}
if rootDir, ok := config["root_dir"].(string); ok {
db.SetSetting("ftp_root_dir", rootDir)
}
if allowDownload, ok := config["allow_download"].(bool); ok {
db.SetSetting("ftp_allow_download", map[bool]string{true: "true", false: "false"}[allowDownload])
}
if allowUpload, ok := config["allow_upload"].(bool); ok {
db.SetSetting("ftp_allow_upload", map[bool]string{true: "true", false: "false"}[allowUpload])
chunkID := string(chunkHeader[0:4])
chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8])
if chunkID == "data" {
return chunkSize
}
// Обновляем FTP сервер
if err := ftp.UpdateConfig(config); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
if ftp.Enabled && !ftp.Running {
go ftp.Start()
} else if !ftp.Enabled && ftp.Running {
go ftp.Stop()
}
sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
return 0
}
// HandleFTPStart - запуск FTP сервера
func HandleFTPStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
// parseWavDurationBySearching ищет fmt и data чанки во всем файле
func parseWavDurationBySearching(file *os.File, fileSize int64) int {
// Ищем fmt чанк
file.Seek(12, 0)
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := int(chunkHeader[4]) | int(chunkHeader[5])<<8 | int(chunkHeader[6])<<16 | int(chunkHeader[7])<<24
if chunkID == "fmt " && chunkSize >= 16 {
// Читаем fmt данные
fmtData := make([]byte, chunkSize)
if _, err := file.Read(fmtData); err != nil {
break
}
audioFormat := int(fmtData[0]) | int(fmtData[1])<<8
if audioFormat == 1 { // PCM
channels := int(fmtData[2]) | int(fmtData[3])<<8
sampleRate := int(fmtData[4]) | int(fmtData[5])<<8 | int(fmtData[6])<<16 | int(fmtData[7])<<24
bitsPerSample := int(fmtData[14]) | int(fmtData[15])<<8
// Теперь ищем data чанк
dataSize := findDataChunkSize(file, fileSize)
if dataSize > 0 && sampleRate > 0 && channels > 0 && bitsPerSample > 0 {
bytesPerSample := bitsPerSample / 8
if bytesPerSample > 0 {
duration := dataSize / (sampleRate * channels * bytesPerSample)
if duration > 0 && duration < 86400 {
return duration
}
}
}
break
}
} else {
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
}
// Защита от бесконечного цикла
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
ftp := GetFTPServer()
if err := ftp.Start(); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"status": "started",
"running": ftp.Running,
})
return estimateDurationFromFileSize(fileSize)
}
// HandleFTPStop - остановка FTP сервера
func HandleFTPStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// findDataChunkSize находит размер data чанка
func findDataChunkSize(file *os.File, fileSize int64) int {
file.Seek(12, 0)
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := int(chunkHeader[4]) | int(chunkHeader[5])<<8 | int(chunkHeader[6])<<16 | int(chunkHeader[7])<<24
ftp := GetFTPServer()
if err := ftp.Stop(); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
if chunkID == "data" {
return chunkSize
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"status": "stopped",
"running": ftp.Running,
})
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
// Защита от бесконечного цикла
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
return 0
}
// estimateDurationFromFileSize оценивает длительность по размеру файла
func estimateDurationFromFileSize(fileSize int64) int {
if fileSize <= 44 {
return 0
}
// Предполагаем стандартные параметры: 16000 Hz, 16 бит, 1 канал = 32000 байт/сек
dataSize := fileSize - 44
duration := int(dataSize / 32000)
if duration < 1 && dataSize > 1000 {
duration = 1
}
log.Printf("estimateDurationFromFileSize: fileSize=%d, estimated duration=%d seconds", fileSize, duration)
return duration
}
// HandleStorageSettings - обработчик для настроек хранилища
@@ -1086,7 +1321,6 @@ func HandleStorageSettings(w http.ResponseWriter, r *http.Request) {
RecorderInstance.OutputDir = req.RecordingsDir
// Пересоздаем папки для существующих каналов
channels := RecorderInstance.GetAllChannels()
for _, ch := range channels {
newChannelDir := filepath.Join(req.RecordingsDir, ch.ID)
@@ -1146,7 +1380,6 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "keep-alive")
// Создаем WAV заголовок
header := make([]byte, 44)
copy(header[0:4], []byte("RIFF"))
copy(header[8:12], []byte("WAVE"))
@@ -1418,13 +1651,11 @@ func HandleChannelSchedule(w http.ResponseWriter, r *http.Request) {
return
}
// Сохраняем расписание в БД
if err := db.SaveSchedule(req); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
// Обновляем канал
ch, exists := RecorderInstance.GetChannel(req.ChannelID)
if exists {
ch.ScheduleEnabled = req.Enabled
@@ -1467,3 +1698,114 @@ func HandleChannelSchedule(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleFTPSettings - обработчик для настроек FTP сервера
func HandleFTPSettings(w http.ResponseWriter, r *http.Request) {
ftp := GetFTPServer()
db := database.GetDB()
switch r.Method {
case http.MethodGet:
ftpEnabled, _ := db.GetSetting("ftp_enabled")
ftpPort, _ := db.GetSetting("ftp_port")
ftpUsername, _ := db.GetSetting("ftp_username")
ftpRootDir, _ := db.GetSetting("ftp_root_dir")
ftpAllowDownload, _ := db.GetSetting("ftp_allow_download")
ftpAllowUpload, _ := db.GetSetting("ftp_allow_upload")
config := map[string]interface{}{
"enabled": ftpEnabled == "true",
"port": ftpPort,
"username": ftpUsername,
"password": "********",
"root_dir": ftpRootDir,
"allow_download": ftpAllowDownload == "true",
"allow_upload": ftpAllowUpload == "true",
"running": ftp.Running,
}
sendJSONResponse(w, http.StatusOK, config)
case http.MethodPost:
var config map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
sendJSONError(w, "Invalid request body", http.StatusBadRequest)
return
}
if enabled, ok := config["enabled"].(bool); ok {
db.SetSetting("ftp_enabled", map[bool]string{true: "true", false: "false"}[enabled])
}
if port, ok := config["port"].(float64); ok {
db.SetSetting("ftp_port", strconv.Itoa(int(port)))
}
if username, ok := config["username"].(string); ok {
db.SetSetting("ftp_username", username)
}
if password, ok := config["password"].(string); ok && password != "" && password != "********" {
db.SetSetting("ftp_password", password)
}
if rootDir, ok := config["root_dir"].(string); ok {
db.SetSetting("ftp_root_dir", rootDir)
}
if allowDownload, ok := config["allow_download"].(bool); ok {
db.SetSetting("ftp_allow_download", map[bool]string{true: "true", false: "false"}[allowDownload])
}
if allowUpload, ok := config["allow_upload"].(bool); ok {
db.SetSetting("ftp_allow_upload", map[bool]string{true: "true", false: "false"}[allowUpload])
}
if err := ftp.UpdateConfig(config); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
if ftp.Enabled && !ftp.Running {
go ftp.Start()
} else if !ftp.Enabled && ftp.Running {
go ftp.Stop()
}
sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleFTPStart - запуск FTP сервера
func HandleFTPStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ftp := GetFTPServer()
if err := ftp.Start(); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"status": "started",
"running": ftp.Running,
})
}
// HandleFTPStop - остановка FTP сервера
func HandleFTPStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ftp := GetFTPServer()
if err := ftp.Stop(); err != nil {
sendJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"status": "stopped",
"running": ftp.Running,
})
}
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+1 -2
View File
@@ -148,7 +148,7 @@ func (s *Server) Run() error {
defer cancel()
go CleanupExpiredSessions(ctx)
go cleanupClosedWebSockets() // Запускаем очистку WebSocket соединений
go cleanupClosedWebSockets()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
@@ -204,7 +204,6 @@ func stopAllRecordings() {
ch.StopRecording()
}
// Сохраняем состояние в БД
if err := db.SetChannelState(id, ch.IsRecording); err != nil {
log.Printf("Failed to save state for channel %s: %v", id, err)
} else {
Regular → Executable
View File
Regular → Executable
+1 -5
View File
@@ -68,7 +68,6 @@ func GetSMBClient() *SMBClient {
// formatAddress форматирует адрес с поддержкой IPv6
func formatAddress(host string, port int) string {
// Проверяем, является ли хост IPv6 адресом
if strings.Contains(host, ":") && !strings.Contains(host, "[") {
return fmt.Sprintf("[%s]:%d", host, port)
}
@@ -215,7 +214,6 @@ func (s *SMBClient) UploadFile(localPath string, remoteFolder string, remoteFile
s.mu.RLock()
defer s.mu.RUnlock()
// Проверяем существование файла
if _, err := os.Stat(localPath); os.IsNotExist(err) {
log.Printf("File does not exist: %s", localPath)
return fmt.Errorf("file does not exist: %s", localPath)
@@ -393,15 +391,14 @@ func (s *SMBClient) ProcessRetryQueue() {
}
}
// updateRecordingStatus обновляет статус SMB в БД
func (s *SMBClient) updateRecordingStatus(filePath string, uploaded bool, errorMsg string) {
// Получаем экземпляр базы данных
db := database.GetDB()
if db == nil {
log.Printf("Database not available")
return
}
// Извлекаем channel_id и filename из пути
parts := strings.Split(filePath, string(os.PathSeparator))
if len(parts) < 2 {
log.Printf("Cannot parse file path: %s", filePath)
@@ -411,7 +408,6 @@ func (s *SMBClient) updateRecordingStatus(filePath string, uploaded bool, errorM
filename := parts[len(parts)-1]
channelID := parts[len(parts)-2]
// Ищем запись в базе данных
recordings, err := db.GetRecordingsByChannel(channelID, 1000)
if err != nil {
log.Printf("Error finding recording: %v", err)
Regular → Executable
View File
Regular → Executable
+24 -32
View File
@@ -3,7 +3,7 @@ import { checkAuth, logout, changePassword } from './modules/api.js';
import { disconnectWebSocket, initWebSocket } from './modules/websocket.js';
import { initTabs, showNotification } from './modules/ui.js';
import { loadDevices } from './modules/devices.js';
import { loadRecordings, downloadRecording } from './modules/recordings.js';
import { loadRecordings, downloadRecording, deleteSelectedRecordings } from './modules/recordings.js';
import { togglePlay } from './modules/player.js';
import { initOscilloscope, drawWaveform } from './modules/oscilloscope.js';
import {
@@ -35,6 +35,7 @@ window.getCurrentChannelId = getCurrentChannelId;
window.loadStatus = loadStatus;
window.togglePlayWrapper = togglePlay;
window.downloadRecordingWrapper = downloadRecording;
window.deleteSelectedRecordings = deleteSelectedRecordings;
window.showNotification = showNotification;
window.getChannels = getChannels;
window.setCurrentChannelId = setCurrentChannelId;
@@ -197,7 +198,6 @@ function bindEventHandlers() {
activeChannelSelect.onchange = (e) => {
if (e.target.value) {
selectChannel(e.target.value, false);
// Останавливаем прослушивание при смене канала
stopLivePlayback();
}
};
@@ -299,6 +299,27 @@ function bindEventHandlers() {
showSmbPasswordBtn._hasHandler = true;
console.log('Show SMB password button handler attached');
}
// Удаление выбранных записей
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
if (deleteSelectedBtn && !deleteSelectedBtn._hasHandler) {
deleteSelectedBtn.onclick = deleteSelectedRecordings;
deleteSelectedBtn._hasHandler = true;
console.log('Delete selected button handler attached');
}
// Выбрать все записи
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox && !selectAllCheckbox._hasHandler) {
selectAllCheckbox.onchange = () => {
const isChecked = selectAllCheckbox.checked;
document.querySelectorAll('.recording-checkbox').forEach(checkbox => {
checkbox.checked = isChecked;
checkbox.dispatchEvent(new Event('change'));
});
};
selectAllCheckbox._hasHandler = true;
}
}
// Функция для наблюдения за изменениями DOM
@@ -318,43 +339,31 @@ function observeDOM() {
document.addEventListener('DOMContentLoaded', async () => {
console.log('DOM загружен, инициализация приложения...');
// Инициализация осциллографа
initOscilloscope();
// Проверка аутентификации
const isAuth = await checkAuth();
if (!isAuth) return;
// Инициализация вкладок
initTabs();
// Запуск наблюдения за DOM для динамической привязки обработчиков
const domObserver = observeDOM();
// Первоначальная привязка обработчиков
setTimeout(() => {
bindEventHandlers();
}, 100);
// Загрузка данных - сначала каналы, потом устройства
await loadChannels(false);
await loadDevices();
// Загрузка записей после инициализации
await loadRecordings();
// Загрузка настроек хранилища, FTP и SMB
await loadStorageSettings();
await loadFTPSettings();
await loadSMBSettings();
// Загрузка статуса для текущего канала
const currentChannel = getCurrentChannelId();
if (currentChannel) {
await loadStatus();
}
// Периодическое обновление статуса
if (updateInterval) clearInterval(updateInterval);
updateInterval = setInterval(async () => {
const currentChannelId = getCurrentChannelId();
@@ -364,7 +373,6 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadChannels(true);
}, 5000);
// Обработка смены вкладки
document.addEventListener('tabChanged', async (e) => {
const tabId = e.detail.tabId;
if (tabId === 'archive') {
@@ -388,12 +396,10 @@ document.addEventListener('DOMContentLoaded', async () => {
setTimeout(() => bindEventHandlers(), 100);
});
// Обработка обновления статуса каналов
document.addEventListener('channelStatusUpdate', () => {
loadChannels(true);
});
// Инициализация информации о качестве
setTimeout(() => {
const channelsSelect = document.getElementById('channels');
if (channelsSelect) {
@@ -402,7 +408,6 @@ document.addEventListener('DOMContentLoaded', async () => {
bindEventHandlers();
}, 500);
// Восстановление выбранного канала из sessionStorage
const savedChannel = sessionStorage.getItem('selectedChannel');
if (savedChannel) {
setTimeout(async () => {
@@ -413,7 +418,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}, 1000);
}
// Периодическая перепривязка обработчиков (на всякий случай)
setInterval(() => {
bindEventHandlers();
}, 2000);
@@ -421,7 +425,6 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log('Приложение инициализировано');
});
// Сохраняем выбранный канал при перезагрузке
window.addEventListener('beforeunload', () => {
const currentChannelId = getCurrentChannelId();
if (currentChannelId) {
@@ -433,15 +436,4 @@ window.addEventListener('beforeunload', () => {
}
disconnectWebSocket();
stopLivePlayback();
});
// Удаление выбранных записей
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
if (deleteSelectedBtn && !deleteSelectedBtn._hasHandler) {
deleteSelectedBtn.onclick = () => {
import('./modules/recordings.js').then(module => {
module.deleteSelectedRecordings();
});
};
deleteSelectedBtn._hasHandler = true;
console.log('Delete selected button handler attached');
}
});
Regular → Executable
View File
Regular → Executable
+4 -4
View File
@@ -33,7 +33,7 @@ function getNextAvailableChannelNumber() {
// Извлекаем все номера из существующих каналов
const numbers = [];
for (const ch of channels) {
const match = ch.id.match(/channel_(\d+)/);
const match = ch.id.match(/channel(\d+)/);
if (match) {
numbers.push(parseInt(match[1]));
}
@@ -195,8 +195,8 @@ export async function loadChannels(skipConfigLoad = false) {
// Сортируем каналы по номеру для стабильного порядка
const sortedChannels = [...newChannels].sort((a, b) => {
const numA = parseInt(a.id.split('_').pop()) || 0;
const numB = parseInt(b.id.split('_').pop()) || 0;
const numA = parseInt(a.id.match(/channel(\d+)/)?.[1] || 0);
const numB = parseInt(b.id.match(/channel(\d+)/)?.[1] || 0);
return numA - numB;
});
@@ -602,7 +602,7 @@ export async function saveChannelConfig() {
export async function addChannel() {
const nextNumber = getNextAvailableChannelNumber();
const channelId = `channel_${nextNumber}`;
const channelId = `channel${nextNumber}`;
const defaultName = `Канал ${nextNumber}`;
const channelName = prompt('Введите название канала:', defaultName);
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+11 -2
View File
@@ -39,8 +39,17 @@ export function formatBytes(bytes) {
// formatDuration - форматирование длительности в секундах
export function formatDuration(seconds) {
if (!seconds || seconds === 0 || seconds < 0) return '0:00';
if (seconds > 86400) return '>24:00:00';
if (!seconds || seconds === 0 || seconds < 0 || isNaN(seconds)) {
return '0:00';
}
// Округляем до целых секунд
seconds = Math.round(seconds);
// Ограничиваем максимальное значение
if (seconds > 86400) {
seconds = 0;
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File