diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7a3ecd5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Используйте IntelliSense, чтобы узнать о возможных атрибутах. + // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. + // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Запустить Chrome на localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/data/audio_recorder.db b/data/audio_recorder.db index ed7e657..3b5274a 100644 Binary files a/data/audio_recorder.db and b/data/audio_recorder.db differ diff --git a/database/database.go b/database/database.go index d956ea1..621c402 100755 --- a/database/database.go +++ b/database/database.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -20,17 +21,27 @@ type DB struct { } type ChannelConfigDB struct { - ID string `json:"id"` - Name string `json:"name"` - Device string `json:"device"` - Duration int `json:"duration"` - SampleRate int `json:"sample_rate"` - Channels int `json:"channels"` - Format string `json:"format"` - AutoRestart bool `json:"auto_restart"` - SMBFolder string `json:"smb_folder"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Device string `json:"device"` + Duration int `json:"duration"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + Format string `json:"format"` + AutoRestart bool `json:"auto_restart"` + SMBFolder string `json:"smb_folder"` + + // Видео поля + ChannelType string `json:"channel_type"` + VideoDevice string `json:"video_device"` + VideoResolution string `json:"video_resolution"` + VideoFramerate int `json:"video_framerate"` + VideoBitrate int `json:"video_bitrate"` + VideoCodec string `json:"video_codec"` + VideoAudioOffset int `json:"video_audio_offset"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type RecordingDB struct { @@ -114,6 +125,11 @@ func (db *DB) initDB() error { return err } + // Выполняем миграцию для добавления видео колонок + if err := db.migrateVideoColumns(); err != nil { + log.Printf("Warning: video columns migration error: %v", err) + } + log.Println("Database initialized successfully") return nil } @@ -130,6 +146,13 @@ func (db *DB) createTables() error { format TEXT DEFAULT 'low', auto_restart INTEGER DEFAULT 0, smb_folder TEXT DEFAULT '', + channel_type TEXT DEFAULT 'audio', + video_device TEXT DEFAULT '', + video_resolution TEXT DEFAULT '1280x720', + video_framerate INTEGER DEFAULT 30, + video_bitrate INTEGER DEFAULT 2000, + video_codec TEXT DEFAULT 'libx264', + video_audio_offset INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, @@ -223,22 +246,23 @@ func (db *DB) createTables() error { failed_attempts INTEGER DEFAULT 0, locked_until DATETIME )`, + `CREATE TABLE IF NOT EXISTS video_recordings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id TEXT NOT NULL, - filename TEXT NOT NULL, - file_path TEXT NOT NULL, - size INTEGER DEFAULT 0, - duration INTEGER DEFAULT 0, - width INTEGER DEFAULT 0, - height INTEGER DEFAULT 0, - framerate INTEGER DEFAULT 0, - bitrate INTEGER DEFAULT 0, - codec TEXT DEFAULT '', - status TEXT DEFAULT 'recording', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - completed_at DATETIME, - FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id TEXT NOT NULL, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + size INTEGER DEFAULT 0, + duration INTEGER DEFAULT 0, + width INTEGER DEFAULT 0, + height INTEGER DEFAULT 0, + framerate INTEGER DEFAULT 0, + bitrate INTEGER DEFAULT 0, + codec TEXT DEFAULT '', + status TEXT DEFAULT 'recording', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE )`, } @@ -251,6 +275,60 @@ func (db *DB) createTables() error { return nil } +// migrateVideoColumns добавляет колонки для видео в существующую таблицу channels +func (db *DB) migrateVideoColumns() error { + columns := []struct { + name string + colType string + defaultVal string + }{ + {"channel_type", "TEXT", "'audio'"}, + {"video_device", "TEXT", "''"}, + {"video_resolution", "TEXT", "'1280x720'"}, + {"video_framerate", "INTEGER", "30"}, + {"video_bitrate", "INTEGER", "2000"}, + {"video_codec", "TEXT", "'libx264'"}, + {"video_audio_offset", "INTEGER", "0"}, + } + + for _, col := range columns { + // Проверяем существует ли колонка + var count int + query := `SELECT COUNT(*) FROM pragma_table_info('channels') WHERE name = ?` + if err := db.conn.QueryRow(query, col.name).Scan(&count); err != nil { + log.Printf("Warning: failed to check column %s: %v", col.name, err) + continue + } + + if count == 0 { + alterQuery := fmt.Sprintf("ALTER TABLE channels ADD COLUMN %s %s DEFAULT %s", + col.name, col.colType, col.defaultVal) + if _, err := db.conn.Exec(alterQuery); err != nil { + log.Printf("Warning: failed to add column %s: %v", col.name, err) + } else { + log.Printf("Added column %s to channels table", col.name) + } + } + } + + // Обновляем существующие записи, устанавливая значения по умолчанию + updateQuery := `UPDATE channels SET + channel_type = COALESCE(channel_type, 'audio'), + video_device = COALESCE(video_device, ''), + video_resolution = COALESCE(video_resolution, '1280x720'), + video_framerate = COALESCE(video_framerate, 30), + video_bitrate = COALESCE(video_bitrate, 2000), + video_codec = COALESCE(video_codec, 'libx264'), + video_audio_offset = COALESCE(video_audio_offset, 0) + WHERE channel_type IS NULL OR channel_type = ''` + + if _, err := db.conn.Exec(updateQuery); err != nil { + log.Printf("Warning: failed to update existing channels: %v", err) + } + + return nil +} + // ========== Channel methods ========== func (db *DB) SaveChannel(config ChannelConfigDB) error { @@ -260,13 +338,18 @@ func (db *DB) SaveChannel(config ChannelConfigDB) error { oldConfig, _ := db.getChannel(config.ID) query := `INSERT OR REPLACE INTO channels - (id, name, device, duration, sample_rate, channels, format, auto_restart, smb_folder, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)` + (id, name, device, duration, sample_rate, channels, format, auto_restart, smb_folder, + channel_type, video_device, video_resolution, video_framerate, video_bitrate, + video_codec, video_audio_offset, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)` _, err := db.conn.Exec(query, config.ID, config.Name, config.Device, config.Duration, config.SampleRate, config.Channels, config.Format, - config.AutoRestart, config.SMBFolder) + config.AutoRestart, config.SMBFolder, + config.ChannelType, config.VideoDevice, config.VideoResolution, + config.VideoFramerate, config.VideoBitrate, config.VideoCodec, + config.VideoAudioOffset) if err != nil { log.Printf("Error saving channel %s: %v", config.ID, err) @@ -283,14 +366,22 @@ func (db *DB) SaveChannel(config ChannelConfigDB) error { } func (db *DB) getChannel(id string) (*ChannelConfigDB, error) { - query := `SELECT id, name, device, duration, sample_rate, channels, format, auto_restart, smb_folder, created_at, updated_at + query := `SELECT id, name, device, duration, sample_rate, channels, format, auto_restart, smb_folder, + channel_type, video_device, video_resolution, video_framerate, video_bitrate, + video_codec, video_audio_offset, created_at, updated_at FROM channels WHERE id = ?` var config ChannelConfigDB + var channelType, videoDevice, videoResolution, videoCodec sql.NullString + var videoFramerate, videoBitrate, videoAudioOffset sql.NullInt64 + err := db.conn.QueryRow(query, id).Scan( &config.ID, &config.Name, &config.Device, &config.Duration, &config.SampleRate, &config.Channels, &config.Format, - &config.AutoRestart, &config.SMBFolder, &config.CreatedAt, &config.UpdatedAt) + &config.AutoRestart, &config.SMBFolder, + &channelType, &videoDevice, &videoResolution, + &videoFramerate, &videoBitrate, &videoCodec, + &videoAudioOffset, &config.CreatedAt, &config.UpdatedAt) switch { case err == sql.ErrNoRows: @@ -299,6 +390,49 @@ func (db *DB) getChannel(id string) (*ChannelConfigDB, error) { return nil, err } + // Установка значений по умолчанию для NULL значений + if channelType.Valid { + config.ChannelType = channelType.String + } else { + config.ChannelType = "audio" + } + + if videoDevice.Valid { + config.VideoDevice = videoDevice.String + } else { + config.VideoDevice = "" + } + + if videoResolution.Valid { + config.VideoResolution = videoResolution.String + } else { + config.VideoResolution = "1280x720" + } + + if videoFramerate.Valid { + config.VideoFramerate = int(videoFramerate.Int64) + } else { + config.VideoFramerate = 30 + } + + if videoBitrate.Valid { + config.VideoBitrate = int(videoBitrate.Int64) + } else { + config.VideoBitrate = 2000 + } + + if videoCodec.Valid { + config.VideoCodec = videoCodec.String + } else { + config.VideoCodec = "libx264" + } + + if videoAudioOffset.Valid { + config.VideoAudioOffset = int(videoAudioOffset.Int64) + } else { + config.VideoAudioOffset = 0 + } + return &config, nil } @@ -312,7 +446,9 @@ func (db *DB) GetAllChannels() ([]ChannelConfigDB, error) { db.mu.RLock() defer db.mu.RUnlock() - query := `SELECT id, name, device, duration, sample_rate, channels, format, auto_restart, smb_folder, created_at, updated_at + query := `SELECT id, name, device, duration, sample_rate, channels, format, auto_restart, smb_folder, + channel_type, video_device, video_resolution, video_framerate, video_bitrate, + video_codec, video_audio_offset, created_at, updated_at FROM channels ORDER BY created_at` rows, err := db.conn.Query(query) @@ -324,13 +460,63 @@ func (db *DB) GetAllChannels() ([]ChannelConfigDB, error) { var channels []ChannelConfigDB for rows.Next() { var config ChannelConfigDB + var channelType, videoDevice, videoResolution, videoCodec sql.NullString + var videoFramerate, videoBitrate, videoAudioOffset sql.NullInt64 + err := rows.Scan( &config.ID, &config.Name, &config.Device, &config.Duration, &config.SampleRate, &config.Channels, &config.Format, - &config.AutoRestart, &config.SMBFolder, &config.CreatedAt, &config.UpdatedAt) + &config.AutoRestart, &config.SMBFolder, + &channelType, &videoDevice, &videoResolution, + &videoFramerate, &videoBitrate, &videoCodec, + &videoAudioOffset, &config.CreatedAt, &config.UpdatedAt) if err != nil { return nil, err } + + // Установка значений по умолчанию для NULL значений + if channelType.Valid { + config.ChannelType = channelType.String + } else { + config.ChannelType = "audio" + } + + if videoDevice.Valid { + config.VideoDevice = videoDevice.String + } else { + config.VideoDevice = "" + } + + if videoResolution.Valid { + config.VideoResolution = videoResolution.String + } else { + config.VideoResolution = "1280x720" + } + + if videoFramerate.Valid { + config.VideoFramerate = int(videoFramerate.Int64) + } else { + config.VideoFramerate = 30 + } + + if videoBitrate.Valid { + config.VideoBitrate = int(videoBitrate.Int64) + } else { + config.VideoBitrate = 2000 + } + + if videoCodec.Valid { + config.VideoCodec = videoCodec.String + } else { + config.VideoCodec = "libx264" + } + + if videoAudioOffset.Valid { + config.VideoAudioOffset = int(videoAudioOffset.Int64) + } else { + config.VideoAudioOffset = 0 + } + channels = append(channels, config) } @@ -845,6 +1031,28 @@ func (db *DB) logChannelChanges(old, new *ChannelConfigDB, changedBy string) { if old.SMBFolder != new.SMBFolder { db.logSettingChange("channels", new.ID, "smb_folder", old.SMBFolder, new.SMBFolder, changedBy) } + // Видео настройки + if old.ChannelType != new.ChannelType { + db.logSettingChange("channels", new.ID, "channel_type", old.ChannelType, new.ChannelType, changedBy) + } + if old.VideoDevice != new.VideoDevice { + db.logSettingChange("channels", new.ID, "video_device", old.VideoDevice, new.VideoDevice, changedBy) + } + if old.VideoResolution != new.VideoResolution { + db.logSettingChange("channels", new.ID, "video_resolution", old.VideoResolution, new.VideoResolution, changedBy) + } + if old.VideoFramerate != new.VideoFramerate { + db.logSettingChange("channels", new.ID, "video_framerate", intToString(old.VideoFramerate), intToString(new.VideoFramerate), changedBy) + } + if old.VideoBitrate != new.VideoBitrate { + db.logSettingChange("channels", new.ID, "video_bitrate", intToString(old.VideoBitrate), intToString(new.VideoBitrate), changedBy) + } + if old.VideoCodec != new.VideoCodec { + db.logSettingChange("channels", new.ID, "video_codec", old.VideoCodec, new.VideoCodec, changedBy) + } + if old.VideoAudioOffset != new.VideoAudioOffset { + db.logSettingChange("channels", new.ID, "video_audio_offset", intToString(old.VideoAudioOffset), intToString(new.VideoAudioOffset), changedBy) + } } func (db *DB) GetSettingsHistory(limit int) ([]map[string]interface{}, error) { diff --git a/logs.json b/logs.json index 37052c9..e0d65e1 100644 --- a/logs.json +++ b/logs.json @@ -295,3 +295,102 @@ {"id":452,"timestamp":"2026-06-10T16:43:54.469945728+07:00","level":"INFO","category":"WIREGUARD","message":"WireGuard запущен успешно"} {"id":453,"timestamp":"2026-06-10T16:44:04.099343908+07:00","level":"WARNING","category":"OPENVPN","message":"Подключение не установлено, проверьте логи"} {"id":454,"timestamp":"2026-06-10T16:44:58.629865195+07:00","level":"INFO","category":"OPENVPN","message":"Остановка OpenVPN"} +{"id":455,"timestamp":"2026-06-11T08:35:29.618048264+07:00","level":"INFO","category":"OPENVPN","message":"Восстановление подключения после перезагрузки"} +{"id":456,"timestamp":"2026-06-11T08:35:29.618542557+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: wireguard"} +{"id":457,"timestamp":"2026-06-11T08:35:34.643389231+07:00","level":"INFO","category":"WIREGUARD","message":"Запуск WireGuard | Интерфейс: wg0"} +{"id":458,"timestamp":"2026-06-11T08:35:34.645427059+07:00","level":"INFO","category":"OPENVPN","message":"Запуск OpenVPN | Конфиг: /home/dnd/pfSense-UDP4-1194-DND-config.ovpn"} +{"id":459,"timestamp":"2026-06-11T08:35:34.648114473+07:00","level":"INFO","category":"OPENVPN","message":"OpenVPN запущен, ожидание подключения..."} +{"id":460,"timestamp":"2026-06-11T08:35:34.858237227+07:00","level":"INFO","category":"WIREGUARD","message":"WireGuard запущен успешно"} +{"id":461,"timestamp":"2026-06-11T08:35:44.659283013+07:00","level":"WARNING","category":"OPENVPN","message":"Подключение не установлено, проверьте логи"} +{"id":462,"timestamp":"2026-06-11T08:36:12.603766985+07:00","level":"INFO","category":"OPENVPN","message":"Остановка OpenVPN"} +{"id":463,"timestamp":"2026-06-11T08:36:56.800733483+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":464,"timestamp":"2026-06-11T08:49:59.291459949+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":465,"timestamp":"2026-06-11T08:49:59.299236184+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":466,"timestamp":"2026-06-11T08:50:19.331116698+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-11 08-50-19.wav"} +{"id":467,"timestamp":"2026-06-11T08:50:41.215507399+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":468,"timestamp":"2026-06-11T08:50:41.233737179+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":469,"timestamp":"2026-06-11T08:50:51.784094037+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-11 08-50-51.wav"} +{"id":470,"timestamp":"2026-06-11T08:54:08.083840846+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":471,"timestamp":"2026-06-11T08:54:08.100799753+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":472,"timestamp":"2026-06-11T09:44:54.153781939+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":473,"timestamp":"2026-06-11T09:44:54.153831491+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":474,"timestamp":"2026-06-11T09:45:45.074318639+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":475,"timestamp":"2026-06-11T09:46:11.592258028+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":476,"timestamp":"2026-06-11T09:46:11.617861986+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":477,"timestamp":"2026-06-11T09:46:23.469837207+07:00","level":"INFO","category":"RECORDING","message":"Запись: ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_09-46-23.mp4 | Длительность: 1м 0с"} +{"id":478,"timestamp":"2026-06-11T09:46:53.180643045+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_09-46-23.mp4 | Папка: channel1"} +{"id":479,"timestamp":"2026-06-11T09:48:15.372621915+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":480,"timestamp":"2026-06-11T09:48:15.409122328+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":481,"timestamp":"2026-06-11T09:48:19.040243829+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-11 09-48-19.wav"} +{"id":482,"timestamp":"2026-06-11T09:48:21.686939937+07:00","level":"INFO","category":"VIDEO","message":"Синхронная запись запущена | Канал: channel1"} +{"id":483,"timestamp":"2026-06-11T09:48:21.686974575+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: mixed_2026-06-11_09-48-21.mp4 | Длительность: 1м 0с"} +{"id":484,"timestamp":"2026-06-11T09:51:53.13985635+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":485,"timestamp":"2026-06-11T09:51:53.139903325+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":486,"timestamp":"2026-06-11T09:52:54.519113025+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":487,"timestamp":"2026-06-11T09:53:28.771537241+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":488,"timestamp":"2026-06-11T09:53:28.805611157+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":489,"timestamp":"2026-06-11T09:53:34.978879667+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":490,"timestamp":"2026-06-11T09:53:35.013267378+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":491,"timestamp":"2026-06-11T09:53:41.762206646+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-11 09-53-41.wav"} +{"id":492,"timestamp":"2026-06-11T09:53:44.408841321+07:00","level":"INFO","category":"VIDEO","message":"Синхронная запись запущена | Канал: channel1"} +{"id":493,"timestamp":"2026-06-11T09:53:44.408888495+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: mixed_2026-06-11_09-53-44.mp4 | Длительность: 1м 0с"} +{"id":494,"timestamp":"2026-06-11T09:54:35.163192108+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":495,"timestamp":"2026-06-11T09:54:35.200703475+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":496,"timestamp":"2026-06-11T09:54:39.834784507+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-11 09-54-39.wav"} +{"id":497,"timestamp":"2026-06-11T09:55:35.2291069+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":498,"timestamp":"2026-06-11T09:55:35.229155868+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":499,"timestamp":"2026-06-11T10:09:26.562430876+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":500,"timestamp":"2026-06-11T10:09:28.575306705+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-11 10-09-28.wav"} +{"id":501,"timestamp":"2026-06-11T10:10:30.62806718+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11 10-09-28.wav | Папка: channel1"} +{"id":502,"timestamp":"2026-06-11T10:10:30.629318697+07:00","level":"INFO","category":"RECORDING","message":"Запись: ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11 10-09-28.wav | Длительность: 1м 0с | Размер: 1 MB"} +{"id":503,"timestamp":"2026-06-11T10:10:30.63060279+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11 10-09-28.wav | Папка: channel1"} +{"id":504,"timestamp":"2026-06-11T10:15:20.893299271+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":505,"timestamp":"2026-06-11T10:15:20.934865403+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":506,"timestamp":"2026-06-11T10:15:24.14437315+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_10-15-24.mp4 | Длительность: 1м 0с"} +{"id":507,"timestamp":"2026-06-11T10:16:24.170745543+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-15-24.mp4 | Папка: channel1"} +{"id":508,"timestamp":"2026-06-11T10:16:24.171947322+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11_10-15-24.mp4 | Длительность: 1м 0с | Размер: 14 MB"} +{"id":509,"timestamp":"2026-06-11T10:16:24.173207386+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-15-24.mp4 | Папка: channel1"} +{"id":510,"timestamp":"2026-06-11T10:19:12.257700588+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":511,"timestamp":"2026-06-11T10:19:12.257753979+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":512,"timestamp":"2026-06-11T10:21:58.452866241+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":513,"timestamp":"2026-06-11T10:22:22.243359639+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":514,"timestamp":"2026-06-11T10:22:22.28008747+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":515,"timestamp":"2026-06-11T10:22:25.896895117+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_10-22-25.mp4 | Длительность: 1м 0с"} +{"id":516,"timestamp":"2026-06-11T10:23:25.92267812+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-22-25.mp4 | Папка: channel1"} +{"id":517,"timestamp":"2026-06-11T10:23:25.923952866+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11_10-22-25.mp4 | Длительность: 1м 0с | Размер: 14 MB"} +{"id":518,"timestamp":"2026-06-11T10:23:25.925154242+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-22-25.mp4 | Папка: channel1"} +{"id":519,"timestamp":"2026-06-11T10:29:44.019876674+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":520,"timestamp":"2026-06-11T10:29:44.029569614+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":521,"timestamp":"2026-06-11T10:30:58.457976195+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":522,"timestamp":"2026-06-11T10:31:23.153961679+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":523,"timestamp":"2026-06-11T10:31:23.190144689+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":524,"timestamp":"2026-06-11T10:31:26.328853046+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_10-31-26.mp4 | Длительность: 1м 0с"} +{"id":525,"timestamp":"2026-06-11T10:32:26.357400301+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-31-26.mp4 | Папка: channel1"} +{"id":526,"timestamp":"2026-06-11T10:32:26.358956941+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11_10-31-26.mp4 | Длительность: 1м 0с | Размер: 6 MB"} +{"id":527,"timestamp":"2026-06-11T10:32:26.360142563+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-31-26.mp4 | Папка: channel1"} +{"id":528,"timestamp":"2026-06-11T10:32:44.038745727+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":529,"timestamp":"2026-06-11T10:32:44.074410691+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":530,"timestamp":"2026-06-11T10:32:46.293341921+07:00","level":"INFO","category":"RECORDING","message":"Запись: ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_10-32-46.mp4 | Длительность: 1м 0с"} +{"id":531,"timestamp":"2026-06-11T10:33:46.320126373+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-32-46.mp4 | Папка: channel1"} +{"id":532,"timestamp":"2026-06-11T10:33:46.321464394+07:00","level":"INFO","category":"RECORDING","message":"Запись: ВИДЕО ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11_10-32-46.mp4 | Длительность: 1м 0с | Размер: 5 MB"} +{"id":533,"timestamp":"2026-06-11T10:33:46.322704982+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-32-46.mp4 | Папка: channel1"} +{"id":534,"timestamp":"2026-06-11T10:36:23.861819985+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":535,"timestamp":"2026-06-11T10:36:23.861870127+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":536,"timestamp":"2026-06-11T10:37:02.23998958+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":537,"timestamp":"2026-06-11T10:37:32.898081319+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":538,"timestamp":"2026-06-11T10:37:32.933999337+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":539,"timestamp":"2026-06-11T10:37:38.267008225+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_10-37-38.mp4 | Длительность: 30с"} +{"id":540,"timestamp":"2026-06-11T10:38:08.293871402+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-37-38.mp4 | Папка: channel1"} +{"id":541,"timestamp":"2026-06-11T10:38:08.295066178+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11_10-37-38.mp4 | Длительность: 30с | Размер: 3 MB"} +{"id":542,"timestamp":"2026-06-11T10:38:08.296314264+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-37-38.mp4 | Папка: channel1"} +{"id":543,"timestamp":"2026-06-11T10:41:22.842677909+07:00","level":"INFO","category":"VPN","message":"Остановка всех VPN клиентов"} +{"id":544,"timestamp":"2026-06-11T10:41:22.852317478+07:00","level":"INFO","category":"VPN","message":"Все VPN клиенты остановлены"} +{"id":545,"timestamp":"2026-06-11T10:42:03.403988903+07:00","level":"INFO","category":"VPN","message":"VPN Manager инициализирован | VPN по умолчанию: none"} +{"id":546,"timestamp":"2026-06-11T10:42:32.283055534+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | настройки канала = сохранены"} +{"id":547,"timestamp":"2026-06-11T10:42:32.318771179+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel1 | расписание = обновлено"} +{"id":548,"timestamp":"2026-06-11T10:42:36.62756771+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО НАЧАЛО | Канал: channel1 | Файл: 2026-06-11_10-42-36.mp4 | Длительность: 30с"} +{"id":549,"timestamp":"2026-06-11T10:43:05.189238622+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel2 | настройки канала = сохранены"} +{"id":550,"timestamp":"2026-06-11T10:43:05.225466874+07:00","level":"SETTINGS","category":"SETTINGS","message":"Изменение настроек | Канал: channel2 | расписание = обновлено"} +{"id":551,"timestamp":"2026-06-11T10:43:06.754044528+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-42-36.mp4 | Папка: channel1"} +{"id":552,"timestamp":"2026-06-11T10:43:06.756317516+07:00","level":"INFO","category":"RECORDING","message":"Запись: АУДИО+ВИДЕО ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-11_10-42-36.mp4 | Длительность: 30с | Размер: 3 MB"} +{"id":553,"timestamp":"2026-06-11T10:43:06.75937835+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-11_10-42-36.mp4 | Папка: channel1"} diff --git a/recorder/channel.go b/recorder/channel.go index ace5bf4..56bacee 100755 --- a/recorder/channel.go +++ b/recorder/channel.go @@ -1,12 +1,14 @@ package recorder import ( + "bufio" "context" "fmt" "log" "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -21,7 +23,7 @@ import ( type Channel struct { ID string `json:"id"` Name string `json:"name"` - ChannelType string `json:"channel_type"` // audio, video, both + ChannelType string `json:"channel_type"` Device string `json:"device"` IsRecording bool `json:"is_recording"` CurrentFile string `json:"current_file"` @@ -33,12 +35,7 @@ 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"` - EndDate string `json:"end_date"` - EndTime string `json:"end_time"` - RepeatDaily bool `json:"repeat_daily"` + // Видео настройки VideoDevice string `json:"video_device"` VideoResolution string `json:"video_resolution"` VideoFramerate int `json:"video_framerate"` @@ -46,6 +43,15 @@ type Channel struct { VideoCodec string `json:"video_codec"` VideoAudioOffset int `json:"video_audio_offset"` + // Расписание + ScheduleEnabled bool `json:"schedule_enabled"` + StartDate string `json:"start_date"` + StartTime string `json:"start_time"` + EndDate string `json:"end_date"` + EndTime string `json:"end_time"` + RepeatDaily bool `json:"repeat_daily"` + + // Внутренние поля cmd *exec.Cmd cancelFunc context.CancelFunc ctx context.Context @@ -125,20 +131,27 @@ func NewChannel(id, name, device string, duration, sampleRate, channels int, for audioDataSize := getOptimalAudioBufferSize(sampleRate, channels) 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, audioDataSize), - stopChan: make(chan struct{}), - ScheduleEnabled: false, + 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, audioDataSize), + stopChan: make(chan struct{}), + ScheduleEnabled: false, + ChannelType: "audio", + VideoDevice: "", + VideoResolution: "640x480", + VideoFramerate: 30, + VideoBitrate: 2000, + VideoCodec: "libx264", + VideoAudioOffset: 0, } } @@ -159,6 +172,7 @@ func (ch *Channel) saveStateToDB() { } } +// StartRecording - основной метод запуска записи (диспетчер) func (ch *Channel) StartRecording(outputDir string, testDeviceFunc func(string) (bool, string)) error { ch.mu.Lock() defer ch.mu.Unlock() @@ -167,6 +181,21 @@ func (ch *Channel) StartRecording(outputDir string, testDeviceFunc func(string) return fmt.Errorf("recording already in progress") } + // Определяем тип канала + switch ch.ChannelType { + case "video": + return ch.startVideoRecording(outputDir) + case "both": + return ch.startBothRecording(outputDir, testDeviceFunc) + default: // "audio" + return ch.startAudioRecording(outputDir, testDeviceFunc) + } +} + +// startAudioRecording - запись только аудио +func (ch *Channel) startAudioRecording(outputDir string, testDeviceFunc func(string) (bool, string)) error { + log.Printf("Starting audio recording on channel %s", ch.ID) + db := database.GetDB() recordingsDir, err := db.GetSetting("recordings_dir") if err != nil || recordingsDir == "" { @@ -234,7 +263,6 @@ func (ch *Channel) StartRecording(outputDir string, testDeviceFunc func(string) ch.cmd = cmd - // Оптимизированный буфер для архитектуры bufferSize := getOptimalBufferSize() go func() { @@ -340,6 +368,404 @@ func (ch *Channel) StartRecording(outputDir string, testDeviceFunc func(string) return nil } +// supportsMJPEG - проверяет поддержку MJPEG формата камерой (только одно объявление!) +func supportsMJPEG(device string) bool { + cmd := exec.Command("v4l2-ctl", "-d", device, "--list-formats-ext") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), "JPEG") || strings.Contains(string(output), "MJPG") +} + +// startVideoRecording - запись только видео +func (ch *Channel) startVideoRecording(outputDir string) error { + log.Printf("Starting video-only recording on channel %s", ch.ID) + + // Проверяем наличие ffmpeg + if _, err := exec.LookPath("ffmpeg"); err != nil { + return fmt.Errorf("ffmpeg not installed") + } + + // Проверяем видео устройство + if ch.VideoDevice == "" { + return fmt.Errorf("video device not specified") + } + + now := time.Now() + filename := now.Format("2006-01-02_15-04-05") + ".mp4" + + // Сохраняем в ту же папку + channelDir := filepath.Join(outputDir, ch.ID) + if err := os.MkdirAll(channelDir, 0755); err != nil { + return fmt.Errorf("cannot create channel directory: %v", err) + } + + fullPath := filepath.Join(channelDir, filename) + + // Создаем контекст для остановки + ch.stopChan = make(chan struct{}) + ch.stopOnce = sync.Once{} + ch.ctx, ch.cancelFunc = context.WithCancel(context.Background()) + + // Используем параметры из успешного теста test2.mp4 + args := []string{ + "-f", "v4l2", + "-input_format", "mjpeg", + "-framerate", "15", + "-video_size", "640x480", + "-i", ch.VideoDevice, + "-c:v", "libx264", + "-preset", "ultrafast", + "-crf", "23", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-y", fullPath, + } + + cmd := exec.CommandContext(ch.ctx, "ffmpeg", args...) + log.Printf("Video command: ffmpeg %v", args) + + // Захватываем stdout и stderr + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("error creating stderr pipe: %v", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("error creating stdout pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("error starting video recording: %v", err) + } + + ch.cmd = cmd + ch.IsRecording = true + ch.CurrentFile = filename + + // Логируем вывод ffmpeg + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + log.Printf("ffmpeg: %s", line) + + // Проверяем наличие кадров + if strings.Contains(line, "frame=") { + log.Printf("FFMPEG PROGRESS: %s", line) + } + } + }() + + // Читаем stdout для отладки + go func() { + buf := make([]byte, 1024) + for { + select { + case <-ch.ctx.Done(): + return + default: + n, err := stdout.Read(buf) + if n > 0 { + log.Printf("ffmpeg stdout: %s", string(buf[:n])) + } + if err != nil { + return + } + } + } + }() + + // Сохраняем в БД + db := database.GetDB() + recording := &database.RecordingDB{ + ChannelID: ch.ID, + Filename: filename, + FilePath: fullPath, + Status: "recording", + SMBFolder: ch.SMBFolder, + CreatedAt: now, + } + if err := db.CreateRecording(recording); err != nil { + log.Printf("Warning: Failed to save video recording to DB: %v", err) + } + + ch.saveStateToDB() + + // Таймер для остановки + if ch.timer != nil { + ch.timer.Stop() + } + + ch.timer = time.AfterFunc(time.Duration(ch.Duration)*time.Second, func() { + log.Printf("Timer triggered for video channel %s after %d seconds", ch.ID, ch.Duration) + ch.StopRecording() + + // Проверяем созданный файл + if info, err := os.Stat(fullPath); err == nil { + log.Printf("Video file size: %d bytes", info.Size()) + if info.Size() < 10000 { + log.Printf("WARNING: Video file is too small (%d bytes), recording may have failed", info.Size()) + } + } else { + log.Printf("ERROR: Video file not found: %s", fullPath) + } + + if ch.AutoRestart { + time.Sleep(1 * time.Second) + ch.StartRecording(outputDir, nil) + } + }) + + go func() { + err := cmd.Wait() + log.Printf("Video recording process for channel %s finished: %v", ch.ID, err) + + // Дополнительная проверка файла после завершения + if info, err := os.Stat(fullPath); err == nil { + log.Printf("Final video file size: %d bytes", info.Size()) + } + + ch.mu.Lock() + if ch.IsRecording { + ch.IsRecording = false + ch.CurrentFile = "" + ch.saveStateToDB() + } + ch.mu.Unlock() + }() + + log.Printf("✅ Video recording started on channel %s: %s", ch.ID, fullPath) + logger.AddRecording(ch.ID, filename, ch.Duration, 0, "ВИДЕО НАЧАЛО") + + return nil +} + +// getCameraBestSettings - получает оптимальные настройки камеры +func getCameraBestSettings(device string) (format string, width, height, fps int) { + // Получаем список форматов + cmd := exec.Command("v4l2-ctl", "-d", device, "--list-formats-ext") + output, err := cmd.Output() + if err != nil { + return "mjpeg", 640, 480, 15 + } + + lines := strings.Split(string(output), "\n") + var currentFormat string + var maxWidth, maxHeight int + var maxFPS int + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Ищем формат + if strings.Contains(line, "Pixel Format:") { + if strings.Contains(line, "MJPG") || strings.Contains(line, "JPEG") { + currentFormat = "mjpeg" + } else if strings.Contains(line, "YUYV") { + currentFormat = "yuyv422" + } + } + + // Ищем разрешение + if strings.Contains(line, "Size:") && strings.Contains(line, "x") { + // Извлекаем разрешение + re := regexp.MustCompile(`(\d+)x(\d+)`) + matches := re.FindStringSubmatch(line) + if len(matches) >= 3 { + w, _ := strconv.Atoi(matches[1]) + h, _ := strconv.Atoi(matches[2]) + if w*h > maxWidth*maxHeight { + maxWidth = w + maxHeight = h + } + } + } + + // Ищем FPS + if strings.Contains(line, "Interval:") && strings.Contains(line, "fps") { + re := regexp.MustCompile(`(\d+\.?\d*) fps`) + matches := re.FindStringSubmatch(line) + if len(matches) >= 2 { + f, _ := strconv.ParseFloat(matches[1], 32) + if int(f) > maxFPS { + maxFPS = int(f) + } + } + } + } + + if maxWidth == 0 { + maxWidth = 640 + maxHeight = 480 + } + if maxFPS == 0 { + maxFPS = 15 + } + if currentFormat == "" { + currentFormat = "mjpeg" + } + + return currentFormat, maxWidth, maxHeight, maxFPS +} + +// startBothRecording - синхронная запись аудио и видео +func (ch *Channel) startBothRecording(outputDir string, testDeviceFunc func(string) (bool, string)) error { + log.Printf("Starting both (audio+video) recording on channel %s", ch.ID) + + // Проверяем наличие ffmpeg + if _, err := exec.LookPath("ffmpeg"); err != nil { + return fmt.Errorf("ffmpeg not installed. Install: sudo apt install ffmpeg") + } + + // Проверяем видео устройство + if ch.VideoDevice == "" { + return fmt.Errorf("video device not specified") + } + + if _, err := os.Stat(ch.VideoDevice); os.IsNotExist(err) { + return fmt.Errorf("video device %s does not exist", ch.VideoDevice) + } + + now := time.Now() + filename := now.Format("2006-01-02_15-04-05") + ".mp4" + + // Сохраняем в ту же папку + channelDir := filepath.Join(outputDir, ch.ID) + if err := os.MkdirAll(channelDir, 0755); err != nil { + return fmt.Errorf("cannot create channel directory: %v", err) + } + + fullPath := filepath.Join(channelDir, filename) + + // Создаем контекст для остановки + ch.stopChan = make(chan struct{}) + ch.stopOnce = sync.Once{} + ch.ctx, ch.cancelFunc = context.WithCancel(context.Background()) + + // Получаем аудио устройство + audioDevice := "default" + if ch.Device != "" && ch.Device != "default" { + audioDevice = ch.Device + } + + // Формируем команду ffmpeg для микса (видео MJPEG + аудио) + args := []string{ + "-f", "v4l2", + "-input_format", "mjpeg", + "-framerate", "15", + "-video_size", "640x480", + "-i", ch.VideoDevice, + "-f", "alsa", + "-i", audioDevice, + "-c:v", "libx264", + "-preset", "ultrafast", + "-crf", "23", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "128k", + "-y", fullPath, + } + + cmd := exec.CommandContext(ch.ctx, "ffmpeg", args...) + log.Printf("FFmpeg command: ffmpeg %v", args) + + // Захватываем вывод для отладки + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("error creating stderr pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("error starting both recording: %v", err) + } + + ch.cmd = cmd + ch.IsRecording = true + ch.CurrentFile = filename + + // Логируем вывод ffmpeg + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + log.Printf("ffmpeg (%s): %s", ch.ID, line) + } + }() + + // Сохраняем в БД + db := database.GetDB() + recording := &database.RecordingDB{ + ChannelID: ch.ID, + Filename: filename, + FilePath: fullPath, + Status: "recording", + SMBFolder: ch.SMBFolder, + CreatedAt: now, + } + if err := db.CreateRecording(recording); err != nil { + log.Printf("Warning: Failed to save recording to DB: %v", err) + } + + ch.saveStateToDB() + + // Таймер для остановки + if ch.timer != nil { + ch.timer.Stop() + } + + ch.timer = time.AfterFunc(time.Duration(ch.Duration)*time.Second, func() { + log.Printf("Timer triggered for both channel %s after %d seconds", ch.ID, ch.Duration) + + currentFile := ch.CurrentFile + currentPath := fullPath + smbFolder := ch.SMBFolder + + ch.StopRecording() + + if recording.ID > 0 { + db.UpdateRecordingStatus(recording.ID, "completed") + if fileInfo, err := os.Stat(currentPath); err == nil && fileInfo.Size() > 0 { + logger.AddRecording(ch.ID, currentFile, ch.Duration, fileInfo.Size(), "АУДИО+ВИДЕО ЗАВЕРШЕНА") + db.UpdateRecordingStats(recording.ID, fileInfo.Size(), ch.Duration) + } + } + + if smbFolder != "" && onRecordingCompleted != nil { + log.Printf("Calling SMB upload callback for file: %s, folder: %s", currentFile, smbFolder) + onRecordingCompleted(currentFile, currentPath, smbFolder) + } + + if ch.AutoRestart { + log.Printf("Auto-restarting both recording for channel %s", ch.ID) + time.Sleep(1 * time.Second) + if err := ch.StartRecording(outputDir, testDeviceFunc); err != nil { + log.Printf("Auto-restart failed for both channel %s: %v", ch.ID, err) + } + } + }) + + go func() { + err := cmd.Wait() + log.Printf("Both recording process for channel %s finished: %v", ch.ID, err) + ch.mu.Lock() + if ch.IsRecording { + ch.IsRecording = false + ch.CurrentFile = "" + ch.saveStateToDB() + } + ch.mu.Unlock() + }() + + logger.AddRecording(ch.ID, filename, ch.Duration, 0, "АУДИО+ВИДЕО НАЧАЛО") + log.Printf("✅ Both (audio+video) recording started on channel %s: %s", ch.ID, fullPath) + + return nil +} + +// StopRecording - остановка записи func (ch *Channel) StopRecording() { ch.stopOnce.Do(func() { log.Printf("=== STOPPING RECORDING for channel %s ===", ch.ID) @@ -348,17 +774,20 @@ func (ch *Channel) StopRecording() { currentPath := "" smbFolder := ch.SMBFolder - log.Printf("DEBUG: currentFile=%s, smbFolder=%s", currentFile, smbFolder) + log.Printf("DEBUG: currentFile=%s, smbFolder=%s, channelType=%s", currentFile, smbFolder, ch.ChannelType) + // Получаем путь к файлу из команды ch.mu.Lock() if ch.cmd != nil && ch.cmd.Process != nil { + // Ищем путь к файлу в аргументах команды for _, arg := range ch.cmd.Args { - if strings.HasSuffix(arg, ".wav") { + if strings.HasSuffix(arg, ".wav") || strings.HasSuffix(arg, ".mp4") || strings.HasSuffix(arg, ".mkv") { currentPath = arg break } } } + if currentPath == "" && currentFile != "" { db := database.GetDB() recordingsDir, _ := db.GetSetting("recordings_dir") @@ -370,6 +799,38 @@ func (ch *Channel) StopRecording() { log.Printf("DEBUG: currentPath=%s", currentPath) ch.mu.Unlock() + // Отправляем SIGTERM для корректного завершения ffmpeg/arecord + ch.mu.Lock() + if ch.cmd != nil && ch.cmd.Process != nil { + log.Printf("Sending SIGTERM to process %d", ch.cmd.Process.Pid) + + // Отправляем SIGTERM сигнал + if err := ch.cmd.Process.Signal(syscall.SIGTERM); err != nil { + log.Printf("Failed to send SIGTERM: %v, trying kill", err) + ch.cmd.Process.Kill() + } + + // Ждем завершения процесса (максимум 5 секунд) + done := make(chan error, 1) + go func() { + done <- ch.cmd.Wait() + }() + + select { + case <-time.After(5 * time.Second): + log.Printf("Process didn't exit after 5 seconds, killing...") + ch.cmd.Process.Kill() + case err := <-done: + if err != nil { + log.Printf("Process exited with error: %v", err) + } else { + log.Printf("Process exited gracefully") + } + } + } + ch.mu.Unlock() + + // Закрываем канал остановки select { case <-ch.stopChan: log.Printf("stopChan already closed for channel %s", ch.ID) @@ -378,34 +839,19 @@ func (ch *Channel) StopRecording() { log.Printf("stopChan closed for channel %s", ch.ID) } + // Отменяем контекст if ch.cancelFunc != nil { ch.cancelFunc() log.Printf("Context cancelled for channel %s", ch.ID) } - var pid int - if ch.cmd != nil && ch.cmd.Process != nil { - pid = ch.cmd.Process.Pid - log.Printf("Process PID for channel %s: %d", ch.ID, pid) - } - - if pid > 0 { - log.Printf("Killing process %d for channel %s", pid, ch.ID) - if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { - log.Printf("syscall.Kill error: %v", err) - } - killCmd := exec.Command("kill", "-9", strconv.Itoa(pid)) - if output, err := killCmd.CombinedOutput(); err != nil { - log.Printf("kill -9 error: %v, output: %s", err, string(output)) - } else { - log.Printf("kill -9 sent successfully to PID %d", pid) - } - exec.Command("pkill", "-9", "-P", strconv.Itoa(pid)).Run() + // Дополнительная очистка - убиваем все оставшиеся процессы + if ch.ChannelType == "video" || ch.ChannelType == "both" { + exec.Command("killall", "-9", "ffmpeg").Run() } else { - log.Printf("No PID found, killing all arecord processes") exec.Command("killall", "-9", "arecord").Run() - exec.Command("pkill", "-9", "arecord").Run() } + exec.Command("pkill", "-9", "arecord").Run() ch.mu.Lock() if ch.timer != nil { @@ -424,37 +870,29 @@ func (ch *Channel) StopRecording() { ch.saveStateToDB() + // Проверяем существование и размер файла fileExists := false + var fileSize int64 if currentPath != "" { - if _, err := os.Stat(currentPath); err == nil { + if info, err := os.Stat(currentPath); err == nil { fileExists = true + fileSize = info.Size() + log.Printf("File exists: %s, size: %d bytes", currentPath, fileSize) + } else { + log.Printf("File not found: %s, error: %v", currentPath, err) } } - log.Printf("DEBUG: fileExists=%v, currentPath=%s, smbFolder=%s, onRecordingCompleted=%v", - fileExists, currentPath, smbFolder, onRecordingCompleted != nil) + log.Printf("DEBUG: fileExists=%v, fileSize=%d, currentPath=%s, smbFolder=%s, onRecordingCompleted=%v", + fileExists, fileSize, currentPath, smbFolder, onRecordingCompleted != nil) - if fileExists && currentPath != "" && smbFolder != "" && onRecordingCompleted != nil { - log.Printf("✅ Sending file to SMB: %s, folder: %s, path: %s", currentFile, smbFolder, currentPath) - - db := database.GetDB() - if currentFile != "" { - recordings, _ := db.GetRecordingsByChannel(ch.ID, 1000) - for _, rec := range recordings { - if rec.Filename == currentFile { - if fileInfo, err := os.Stat(currentPath); err == nil { - logger.AddRecording(ch.ID, currentFile, ch.Duration, fileInfo.Size(), "ЗАВЕРШЕНА") - db.UpdateRecordingStats(rec.ID, fileInfo.Size(), ch.Duration) - } - db.UpdateRecordingStatus(rec.ID, "completed") - break - } - } - } + // Отправка на SMB если файл существует и не пустой + if fileExists && fileSize > 0 && smbFolder != "" && onRecordingCompleted != nil { + log.Printf("✅ Sending file to SMB: %s, size: %d bytes, folder: %s", currentFile, fileSize, smbFolder) onRecordingCompleted(currentFile, currentPath, smbFolder) } else { - log.Printf("❌ SMB отправка НЕ выполнена: fileExists=%v, currentPath=%s, smbFolder=%s, callback=%v", - fileExists, currentPath, smbFolder, onRecordingCompleted != nil) + log.Printf("❌ SMB отправка НЕ выполнена: fileExists=%v, fileSize=%d, smbFolder=%s, callback=%v", + fileExists, fileSize, smbFolder, onRecordingCompleted != nil) } log.Printf("=== RECORDING STOPPED for channel %s ===", ch.ID) @@ -512,15 +950,22 @@ func (ch *Channel) UpdateConfig(config ChannelConfig) { db := database.GetDB() dbConfig := database.ChannelConfigDB{ - ID: ch.ID, - Name: ch.Name, - Device: ch.Device, - Duration: ch.Duration, - SampleRate: ch.SampleRate, - Channels: ch.Channels, - Format: ch.Format, - AutoRestart: ch.AutoRestart, - SMBFolder: ch.SMBFolder, + ID: ch.ID, + Name: ch.Name, + Device: ch.Device, + Duration: ch.Duration, + SampleRate: ch.SampleRate, + Channels: ch.Channels, + Format: ch.Format, + AutoRestart: ch.AutoRestart, + SMBFolder: ch.SMBFolder, + ChannelType: ch.ChannelType, + VideoDevice: ch.VideoDevice, + VideoResolution: ch.VideoResolution, + VideoFramerate: ch.VideoFramerate, + VideoBitrate: ch.VideoBitrate, + VideoCodec: ch.VideoCodec, + VideoAudioOffset: ch.VideoAudioOffset, } db.SaveChannel(dbConfig) } @@ -622,7 +1067,6 @@ func (ch *Channel) StartSchedule() { ch.ID, targetStart.Format("2006-01-02 15:04:05"), startDuration, targetEnd.Format("2006-01-02 15:04:05"), endDuration) - // Для ARM добавляем небольшую задержку перед стартом if optimization.IsARM() { time.Sleep(500 * time.Millisecond) } diff --git a/recordings/channel1/2026-06-11 09-48-19.wav b/recordings/channel1/2026-06-11 09-48-19.wav new file mode 100644 index 0000000..5384878 Binary files /dev/null and b/recordings/channel1/2026-06-11 09-48-19.wav differ diff --git a/recordings/channel1/2026-06-11 09-53-41.wav b/recordings/channel1/2026-06-11 09-53-41.wav new file mode 100644 index 0000000..c85e824 Binary files /dev/null and b/recordings/channel1/2026-06-11 09-53-41.wav differ diff --git a/recordings/channel1/2026-06-11 09-54-39.wav b/recordings/channel1/2026-06-11 09-54-39.wav new file mode 100644 index 0000000..b369972 Binary files /dev/null and b/recordings/channel1/2026-06-11 09-54-39.wav differ diff --git a/recordings/channel1/2026-06-11 10-09-28.wav b/recordings/channel1/2026-06-11 10-09-28.wav new file mode 100644 index 0000000..4c0a156 Binary files /dev/null and b/recordings/channel1/2026-06-11 10-09-28.wav differ diff --git a/recordings/channel1/2026-06-11_10-15-24.mp4 b/recordings/channel1/2026-06-11_10-15-24.mp4 new file mode 100644 index 0000000..91c5adf Binary files /dev/null and b/recordings/channel1/2026-06-11_10-15-24.mp4 differ diff --git a/recordings/channel1/2026-06-11_10-22-25.mp4 b/recordings/channel1/2026-06-11_10-22-25.mp4 new file mode 100644 index 0000000..cd9ea84 Binary files /dev/null and b/recordings/channel1/2026-06-11_10-22-25.mp4 differ diff --git a/recordings/channel1/2026-06-11_10-31-26.mp4 b/recordings/channel1/2026-06-11_10-31-26.mp4 new file mode 100644 index 0000000..3a4c01a Binary files /dev/null and b/recordings/channel1/2026-06-11_10-31-26.mp4 differ diff --git a/recordings/channel1/2026-06-11_10-32-46.mp4 b/recordings/channel1/2026-06-11_10-32-46.mp4 new file mode 100644 index 0000000..245afdc Binary files /dev/null and b/recordings/channel1/2026-06-11_10-32-46.mp4 differ diff --git a/recordings/channel1/2026-06-11_10-37-38.mp4 b/recordings/channel1/2026-06-11_10-37-38.mp4 new file mode 100644 index 0000000..a4416b0 Binary files /dev/null and b/recordings/channel1/2026-06-11_10-37-38.mp4 differ diff --git a/recordings/channel1/2026-06-11_10-42-36.mp4 b/recordings/channel1/2026-06-11_10-42-36.mp4 new file mode 100644 index 0000000..5672b43 Binary files /dev/null and b/recordings/channel1/2026-06-11_10-42-36.mp4 differ diff --git a/recordings/channel1/videos/2026-06-11_09-46-23.mp4 b/recordings/channel1/videos/2026-06-11_09-46-23.mp4 new file mode 100644 index 0000000..6fa7e85 Binary files /dev/null and b/recordings/channel1/videos/2026-06-11_09-46-23.mp4 differ diff --git a/server/handlers.go b/server/handlers.go index 5c14b86..1074cee 100755 --- a/server/handlers.go +++ b/server/handlers.go @@ -55,7 +55,7 @@ func SetRecorder(r *recorder.Recorder) { RecorderInstance = r } -// ServeLogin отдает страницу логина +// ========== ServeLogin ========== func ServeLogin(w http.ResponseWriter, r *http.Request, staticFiles embed.FS) { data, err := staticFiles.ReadFile("static/login.html") if err != nil { @@ -67,7 +67,7 @@ func ServeLogin(w http.ResponseWriter, r *http.Request, staticFiles embed.FS) { w.Write(data) } -// ServeApp отдает главную страницу приложения +// ========== ServeApp ========== func ServeApp(w http.ResponseWriter, r *http.Request, staticFiles embed.FS) { data, err := staticFiles.ReadFile("static/index.html") if err != nil { @@ -79,7 +79,7 @@ func ServeApp(w http.ResponseWriter, r *http.Request, staticFiles embed.FS) { w.Write(data) } -// safeWriteJSON безопасно записывает JSON в WebSocket +// ========== WebSocket функции ========== func safeWriteJSON(conn *websocket.Conn, v interface{}) error { if conn == nil { return fmt.Errorf("connection is nil") @@ -110,7 +110,6 @@ func safeWriteJSON(conn *websocket.Conn, v interface{}) error { return conn.WriteJSON(v) } -// cleanupClosedWebSockets - периодическая очистка закрытых соединений func cleanupClosedWebSockets() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() @@ -133,7 +132,7 @@ func cleanupClosedWebSockets() { } } -// HandleWebSocket обрабатывает WebSocket соединения +// ========== HandleWebSocket ========== func HandleWebSocket(w http.ResponseWriter, r *http.Request) { username, ok := CheckAuth(r) if !ok { @@ -158,10 +157,7 @@ func HandleWebSocket(w http.ResponseWriter, r *http.Request) { log.Printf("WebSocket upgrade error for user %s: %v", username, err) return } - defer func() { - conn.Close() - log.Printf("WebSocket connection closed for user %s, channel %s", username, channelID) - }() + defer conn.Close() log.Printf("WebSocket connected for user %s, channel %s", username, channelID) @@ -263,7 +259,7 @@ func HandleWebSocket(w http.ResponseWriter, r *http.Request) { } } -// HandleDevices обрабатывает запросы для работы с аудиоустройствами +// ========== HandleDevices ========== func HandleDevices(w http.ResponseWriter, r *http.Request) { dm := devices.GetManager() @@ -284,7 +280,7 @@ func HandleDevices(w http.ResponseWriter, r *http.Request) { } } -// HandleTestDevice тестирует аудиоустройство +// ========== HandleTestDevice ========== func HandleTestDevice(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -316,7 +312,7 @@ func HandleTestDevice(w http.ResponseWriter, r *http.Request) { }) } -// HandleChannels обрабатывает запросы для управления каналами записи +// ========== HandleChannels ========== func HandleChannels(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -325,18 +321,31 @@ func HandleChannels(w http.ResponseWriter, r *http.Request) { for id, ch := range channels { channelsList = append(channelsList, map[string]interface{}{ - "id": id, - "name": ch.Name, - "device": ch.Device, - "is_recording": ch.IsRecording, - "current_file": ch.CurrentFile, - "duration": ch.Duration, - "sample_rate": ch.SampleRate, - "channels": ch.Channels, - "format": ch.Format, - "auto_restart": ch.AutoRestart, - "smb_folder": ch.SMBFolder, - "created_at": ch.CreatedAt, + "id": id, + "name": ch.Name, + "channel_type": ch.ChannelType, + "device": ch.Device, + "is_recording": ch.IsRecording, + "current_file": ch.CurrentFile, + "duration": ch.Duration, + "sample_rate": ch.SampleRate, + "channels": ch.Channels, + "format": ch.Format, + "auto_restart": ch.AutoRestart, + "smb_folder": ch.SMBFolder, + "created_at": ch.CreatedAt, + "video_device": ch.VideoDevice, + "video_resolution": ch.VideoResolution, + "video_framerate": ch.VideoFramerate, + "video_bitrate": ch.VideoBitrate, + "video_codec": ch.VideoCodec, + "video_audio_offset": ch.VideoAudioOffset, + "schedule_enabled": ch.ScheduleEnabled, + "start_date": ch.StartDate, + "start_time": ch.StartTime, + "end_date": ch.EndDate, + "end_time": ch.EndTime, + "repeat_daily": ch.RepeatDaily, }) } @@ -344,78 +353,169 @@ func HandleChannels(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(channelsList) case http.MethodPost: - var config recorder.ChannelConfig - if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + var req struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` + Device string `json:"device"` + Duration int `json:"duration"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + Format string `json:"format"` + AutoRestart bool `json:"auto_restart"` + SMBFolder string `json:"smb_folder"` + VideoDevice string `json:"video_device"` + VideoResolution string `json:"video_resolution"` + VideoFramerate int `json:"video_framerate"` + VideoBitrate int `json:"video_bitrate"` + VideoCodec string `json:"video_codec"` + VideoAudioOffset int `json:"video_audio_offset"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body: "+err.Error(), http.StatusBadRequest) return } - if config.ChannelID == "" { + if req.ChannelID == "" { sendJSONError(w, "channel_id is required", http.StatusBadRequest) return } - if config.ChannelName == "" { - config.ChannelName = config.ChannelID + if req.ChannelName == "" { + req.ChannelName = req.ChannelID } - if config.Device == "" { - config.Device = "default" + if req.ChannelType == "" { + req.ChannelType = "audio" } - if config.Duration < 5 || config.Duration > 3600 { - config.Duration = 60 + if req.Duration < 5 || req.Duration > 3600 { + req.Duration = 60 } - if config.SampleRate == 0 { - config.SampleRate = 16000 + if req.SampleRate == 0 { + req.SampleRate = 16000 } - if config.Channels != 1 && config.Channels != 2 { - config.Channels = 2 + if req.Channels != 1 && req.Channels != 2 { + req.Channels = 2 } - if config.Format == "" { - config.Format = "low" + if req.Format == "" { + req.Format = "low" } - if _, exists := RecorderInstance.GetChannel(config.ChannelID); !exists { - log.Printf("Creating new channel: %s", config.ChannelID) + if req.VideoResolution == "" { + req.VideoResolution = "1280x720" + } + if req.VideoFramerate == 0 { + req.VideoFramerate = 30 + } + if req.VideoBitrate == 0 { + req.VideoBitrate = 2000 + } + if req.VideoCodec == "" { + req.VideoCodec = "libx264" + } + + if _, exists := RecorderInstance.GetChannel(req.ChannelID); !exists { + log.Printf("Creating new channel: %s (type: %s)", req.ChannelID, req.ChannelType) + newChannel := recorder.NewChannel( - config.ChannelID, - config.ChannelName, - config.Device, - config.Duration, - config.SampleRate, - config.Channels, - config.Format, - config.AutoRestart, + req.ChannelID, + req.ChannelName, + req.Device, + req.Duration, + req.SampleRate, + req.Channels, + req.Format, + req.AutoRestart, RecorderInstance.OutputDir, ) - if config.SMBFolder != "" { - newChannel.SMBFolder = config.SMBFolder + + newChannel.ChannelType = req.ChannelType + newChannel.VideoDevice = req.VideoDevice + newChannel.VideoResolution = req.VideoResolution + newChannel.VideoFramerate = req.VideoFramerate + newChannel.VideoBitrate = req.VideoBitrate + newChannel.VideoCodec = req.VideoCodec + newChannel.VideoAudioOffset = req.VideoAudioOffset + + if req.SMBFolder != "" { + newChannel.SMBFolder = req.SMBFolder } else { - newChannel.SMBFolder = config.ChannelID + newChannel.SMBFolder = req.ChannelID } + RecorderInstance.AddChannel(newChannel) - channelDir := filepath.Join(RecorderInstance.OutputDir, config.ChannelID) + channelDir := filepath.Join(RecorderInstance.OutputDir, req.ChannelID) os.MkdirAll(channelDir, 0755) + + if req.ChannelType == "video" || req.ChannelType == "both" { + videoDir := filepath.Join(channelDir, "videos") + os.MkdirAll(videoDir, 0755) + } } else { - log.Printf("Updating existing channel: %s", config.ChannelID) - RecorderInstance.UpdateChannel(config.ChannelID, config) + log.Printf("Updating existing channel: %s (type: %s)", req.ChannelID, req.ChannelType) + + config := recorder.ChannelConfig{ + ChannelID: req.ChannelID, + ChannelName: req.ChannelName, + Device: req.Device, + Duration: req.Duration, + SampleRate: req.SampleRate, + Channels: req.Channels, + Format: req.Format, + AutoRestart: req.AutoRestart, + SMBFolder: req.SMBFolder, + } + RecorderInstance.UpdateChannel(req.ChannelID, config) + + if ch, exists := RecorderInstance.GetChannel(req.ChannelID); exists { + ch.ChannelType = req.ChannelType + ch.VideoDevice = req.VideoDevice + ch.VideoResolution = req.VideoResolution + ch.VideoFramerate = req.VideoFramerate + ch.VideoBitrate = req.VideoBitrate + ch.VideoCodec = req.VideoCodec + ch.VideoAudioOffset = req.VideoAudioOffset + + db := database.GetDB() + dbConfig := database.ChannelConfigDB{ + ID: ch.ID, + Name: ch.Name, + Device: ch.Device, + Duration: ch.Duration, + SampleRate: ch.SampleRate, + Channels: ch.Channels, + Format: ch.Format, + AutoRestart: ch.AutoRestart, + SMBFolder: ch.SMBFolder, + ChannelType: ch.ChannelType, + VideoDevice: ch.VideoDevice, + VideoResolution: ch.VideoResolution, + VideoFramerate: ch.VideoFramerate, + VideoBitrate: ch.VideoBitrate, + VideoCodec: ch.VideoCodec, + VideoAudioOffset: ch.VideoAudioOffset, + } + db.SaveChannel(dbConfig) + } } sendJSONResponse(w, http.StatusOK, map[string]string{ "status": "ok", "message": "Channel saved successfully", }) + default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } -// HandleChannelsDelete удаляет канал +// ========== HandleChannelsDelete ========== func HandleChannelsDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -423,11 +523,13 @@ func HandleChannelsDelete(w http.ResponseWriter, r *http.Request) { } channelID := strings.TrimPrefix(r.URL.Path, "/api/channels/") - if channelID == "" { + if channelID == "" || channelID == r.URL.Path { sendJSONError(w, "Channel ID required", http.StatusBadRequest) return } + log.Printf("Deleting channel: %s", channelID) + if RecorderInstance.DeleteChannel(channelID) { sendJSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } else { @@ -435,7 +537,7 @@ func HandleChannelsDelete(w http.ResponseWriter, r *http.Request) { } } -// HandleStart запускает запись на канале +// ========== HandleStart ========== func HandleStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -467,7 +569,7 @@ func HandleStart(w http.ResponseWriter, r *http.Request) { return dm.TestDevice(device) } - log.Printf("Starting recording on channel %s with device %s", channelID, ch.Device) + log.Printf("Starting recording on channel %s with device %s, type: %s", channelID, ch.Device, ch.ChannelType) if err := ch.StartRecording(RecorderInstance.OutputDir, testFunc); err != nil { log.Printf("Error starting recording on channel %s: %v", channelID, err) @@ -499,7 +601,7 @@ func HandleStart(w http.ResponseWriter, r *http.Request) { }) } -// HandleStop останавливает запись на канале +// ========== HandleStop ========== func HandleStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -530,25 +632,14 @@ func HandleStop(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) - if ch.IsRecording { - log.Printf("WARNING: Channel %s still shows as recording after stop! Forcing again...", channelID) - exec.Command("killall", "-9", "arecord").Run() - exec.Command("pkill", "-9", "arecord").Run() - time.Sleep(200 * time.Millisecond) - 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) } else { - log.Printf("✅ Channel %s state saved to DB: recording=false", channelID) + 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) - - log.Printf("Stop request completed for channel %s, recording status: %v", channelID, ch.IsRecording) + log.Printf("Stop request completed for channel %s", channelID) sendJSONResponse(w, http.StatusOK, map[string]string{ "status": "stopped", @@ -556,7 +647,7 @@ func HandleStop(w http.ResponseWriter, r *http.Request) { }) } -// HandleRecordings возвращает список записей +// ========== HandleRecordings ========== func HandleRecordings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -648,7 +739,79 @@ func HandleRecordings(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(recordingsList) } -// HandleDeleteRecordings удаляет выбранные записи +// ========== GetWavDuration (исправленная версия) ========== +func GetWavDuration(filePath string) int { + file, err := os.Open(filePath) + if err != nil { + return 0 + } + defer file.Close() + + header := make([]byte, 44) + if _, err := file.Read(header); err != nil { + return 0 + } + + if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { + return 0 + } + + var sampleRate, channels, bitsPerSample, dataSize int + offset := 12 + + for offset < len(header)-8 { + chunkID := string(header[offset : offset+4]) + chunkSize := int(binary.LittleEndian.Uint32(header[offset+4 : offset+8])) + + switch chunkID { + case "fmt ": + if chunkSize >= 16 { + channels = int(binary.LittleEndian.Uint16(header[offset+8 : offset+10])) + sampleRate = int(binary.LittleEndian.Uint32(header[offset+12 : offset+16])) + bitsPerSample = int(binary.LittleEndian.Uint16(header[offset+22 : offset+24])) + } + case "data": + dataSize = chunkSize + } + + offset += 8 + chunkSize + if offset >= len(header)-8 { + break + } + } + + if dataSize == 0 { + file.Seek(12, 0) + for { + chunkHeader := make([]byte, 8) + if _, err := file.Read(chunkHeader); err != nil { + break + } + chunkID := string(chunkHeader[0:4]) + chunkSize := int(binary.LittleEndian.Uint32(chunkHeader[4:8])) + + if chunkID == "data" { + dataSize = chunkSize + break + } + file.Seek(int64(chunkSize), 1) + } + } + + // Проверка на нулевые значения + if sampleRate == 0 || channels == 0 || bitsPerSample == 0 { + return 0 + } + + bytesPerSample := bitsPerSample / 8 + if bytesPerSample == 0 { + return 0 + } + + return dataSize / (sampleRate * channels * bytesPerSample) +} + +// ========== HandleDeleteRecordings ========== func HandleDeleteRecordings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -708,7 +871,7 @@ func HandleDeleteRecordings(w http.ResponseWriter, r *http.Request) { } } -// HandleDownload скачивает файл записи +// ========== HandleDownload ========== func HandleDownload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -762,7 +925,7 @@ func HandleDownload(w http.ResponseWriter, r *http.Request) { io.Copy(w, limitedReader) } -// HandleGetStatus - обработчик для получения статуса записи +// ========== HandleGetStatus ========== func HandleGetStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -798,7 +961,7 @@ func HandleGetStatus(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -// HandleGetConfig - обработчик для получения конфигурации канала +// ========== HandleGetConfig ========== func HandleGetConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -816,14 +979,39 @@ func HandleGetConfig(w http.ResponseWriter, r *http.Request) { return } - config := ch.GetConfig() - log.Printf("GetConfig for channel %s: device=%s, name=%s", channelID, config.Device, config.ChannelName) + config := map[string]interface{}{ + "channel_id": ch.ID, + "channel_name": ch.Name, + "channel_type": ch.ChannelType, + "device": ch.Device, + "duration": ch.Duration, + "sample_rate": ch.SampleRate, + "channels": ch.Channels, + "format": ch.Format, + "auto_restart": ch.AutoRestart, + "smb_folder": ch.SMBFolder, + "video_device": ch.VideoDevice, + "video_resolution": ch.VideoResolution, + "video_framerate": ch.VideoFramerate, + "video_bitrate": ch.VideoBitrate, + "video_codec": ch.VideoCodec, + "video_audio_offset": ch.VideoAudioOffset, + "schedule_enabled": ch.ScheduleEnabled, + "start_date": ch.StartDate, + "start_time": ch.StartTime, + "end_date": ch.EndDate, + "end_time": ch.EndTime, + "repeat_daily": ch.RepeatDaily, + } + + log.Printf("GetConfig for channel %s: type=%s, device=%s, video_device=%s", + channelID, ch.ChannelType, ch.Device, ch.VideoDevice) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(config) } -// HandleDebugState - отладочный эндпоинт +// ========== HandleDebugState ========== func HandleDebugState(w http.ResponseWriter, r *http.Request) { db := database.GetDB() channelID := r.URL.Query().Get("channel") @@ -846,7 +1034,7 @@ func HandleDebugState(w http.ResponseWriter, r *http.Request) { }) } -// HandleForceStart - принудительный запуск записи +// ========== HandleForceStart ========== func HandleForceStart(w http.ResponseWriter, r *http.Request) { channelID := r.URL.Query().Get("channel") if channelID == "" { @@ -862,83 +1050,7 @@ func HandleForceStart(w http.ResponseWriter, r *http.Request) { }) } -// GetWavDuration возвращает длительность WAV файла в секундах -func GetWavDuration(filepath string) int { - file, err := os.Open(filepath) - if err != nil { - return 0 - } - 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 - } - - if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { - return 0 - } - - 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 - - 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" { - 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 { - return int(dataSizeActual / 8000) - } else if dataSizeActual < 3000000 { - return int(dataSizeActual / 32000) - } else if dataSizeActual < 6000000 { - return int(dataSizeActual / 44000) - } else { - return int(dataSizeActual / 192000) - } -} - -// HandleStorageSettings - обработчик для настроек хранилища +// ========== HandleStorageSettings ========== func HandleStorageSettings(w http.ResponseWriter, r *http.Request) { db := database.GetDB() @@ -999,7 +1111,7 @@ func HandleStorageSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleLiveStream - потоковая передача аудио для онлайн прослушивания +// ========== HandleLiveStream ========== func HandleLiveStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1112,7 +1224,6 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) { } }() - // Оптимизированный буфер для ARM bufferSize := 8192 if optimization.IsARM() { bufferSize = 4096 @@ -1153,7 +1264,7 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) { log.Printf("Live stream ended for channel %s", channelID) } -// HandleSMBSettings - обработчик для настроек SMB клиента +// ========== HandleSMBSettings ========== func HandleSMBSettings(w http.ResponseWriter, r *http.Request) { db := database.GetDB() @@ -1260,7 +1371,7 @@ func HandleSMBSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleSMBStatus - получение статуса SMB клиента +// ========== HandleSMBStatus ========== func HandleSMBStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1274,7 +1385,7 @@ func HandleSMBStatus(w http.ResponseWriter, r *http.Request) { }) } -// HandleChannelSchedule - обработчик для управления расписанием канала +// ========== HandleChannelSchedule ========== func HandleChannelSchedule(w http.ResponseWriter, r *http.Request) { db := database.GetDB() @@ -1367,7 +1478,7 @@ func HandleChannelSchedule(w http.ResponseWriter, r *http.Request) { } } -// HandleFTPSettings - обработчик для настроек FTP сервера +// ========== HandleFTPSettings ========== func HandleFTPSettings(w http.ResponseWriter, r *http.Request) { ftp := GetFTPServer() db := database.GetDB() @@ -1440,7 +1551,7 @@ func HandleFTPSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleFTPStart - запуск FTP сервера +// ========== HandleFTPStart ========== func HandleFTPStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1459,7 +1570,7 @@ func HandleFTPStart(w http.ResponseWriter, r *http.Request) { }) } -// HandleFTPStop - остановка FTP сервера +// ========== HandleFTPStop ========== func HandleFTPStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1478,7 +1589,7 @@ func HandleFTPStop(w http.ResponseWriter, r *http.Request) { }) } -// HandleGetLogs - получение логов +// ========== HandleGetLogs ========== func HandleGetLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1503,7 +1614,7 @@ func HandleGetLogs(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, http.StatusOK, logs) } -// HandleClearLogs - очистка логов +// ========== HandleClearLogs ========== func HandleClearLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1514,7 +1625,7 @@ func HandleClearLogs(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) } -// HandleOpenVPNSettings - обработчик настроек OpenVPN +// ========== HandleOpenVPNSettings ========== func HandleOpenVPNSettings(w http.ResponseWriter, r *http.Request) { openvpn := vpn.GetOpenVPNClient() db := database.GetDB() @@ -1579,7 +1690,7 @@ func HandleOpenVPNSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleOpenVPNStatus - получение статуса OpenVPN +// ========== HandleOpenVPNStatus ========== func HandleOpenVPNStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1590,7 +1701,7 @@ func HandleOpenVPNStatus(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, http.StatusOK, openvpn.GetStatus()) } -// HandleOpenVPNStart - запуск OpenVPN +// ========== HandleOpenVPNStart ========== func HandleOpenVPNStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1609,7 +1720,7 @@ func HandleOpenVPNStart(w http.ResponseWriter, r *http.Request) { }) } -// HandleOpenVPNStop - остановка OpenVPN +// ========== HandleOpenVPNStop ========== func HandleOpenVPNStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1627,7 +1738,7 @@ func HandleOpenVPNStop(w http.ResponseWriter, r *http.Request) { }) } -// HandleWireGuardSettings - обработчик настроек WireGuard +// ========== HandleWireGuardSettings ========== func HandleWireGuardSettings(w http.ResponseWriter, r *http.Request) { wireguard := vpn.GetWireGuardClient() db := database.GetDB() @@ -1671,10 +1782,8 @@ func HandleWireGuardSettings(w http.ResponseWriter, r *http.Request) { return } - // Сохраняем enabled в БД db.SetSetting("wireguard_enabled", map[bool]string{true: "true", false: "false"}[req.Enabled]) - // Обновляем конфигурацию err := wireguard.UpdateConfig( req.Interface, req.PrivateKey, @@ -1700,7 +1809,7 @@ func HandleWireGuardSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleWireGuardStatus - статус WireGuard +// ========== HandleWireGuardStatus ========== func HandleWireGuardStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1711,7 +1820,7 @@ func HandleWireGuardStatus(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, http.StatusOK, wireguard.GetStatus()) } -// HandleWireGuardStart - запуск WireGuard +// ========== HandleWireGuardStart ========== func HandleWireGuardStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1730,7 +1839,7 @@ func HandleWireGuardStart(w http.ResponseWriter, r *http.Request) { }) } -// HandleWireGuardStop - остановка WireGuard +// ========== HandleWireGuardStop ========== func HandleWireGuardStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1748,7 +1857,7 @@ func HandleWireGuardStop(w http.ResponseWriter, r *http.Request) { }) } -// HandleVPNSettings - общие настройки VPN +// ========== HandleVPNSettings ========== func HandleVPNSettings(w http.ResponseWriter, r *http.Request) { db := database.GetDB() manager := vpn.GetVPNManager() @@ -1781,7 +1890,7 @@ func HandleVPNSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleVPNStatus - общий статус VPN +// ========== HandleVPNStatus ========== func HandleVPNStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1792,7 +1901,7 @@ func HandleVPNStatus(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, http.StatusOK, manager.GetStatus()) } -// HandleVPNStart - запуск VPN по умолчанию +// ========== HandleVPNStart ========== func HandleVPNStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1807,7 +1916,7 @@ func HandleVPNStart(w http.ResponseWriter, r *http.Request) { }) } -// HandleVPNStop - остановка всех VPN +// ========== HandleVPNStop ========== func HandleVPNStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1822,7 +1931,15 @@ func HandleVPNStop(w http.ResponseWriter, r *http.Request) { }) } -// HandleBrowseDirectory - просмотр директории для выбора файла +// ========== HandleBrowseDirectory ========== +type FileItem struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time"` +} + func HandleBrowseDirectory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1913,88 +2030,33 @@ func HandleBrowseDirectory(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, http.StatusOK, result) } -// FileItem представляет элемент файловой системы -type FileItem struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - ModTime time.Time `json:"mod_time"` -} +// ========== Остальные обработчики (заглушки) ========== -// HandleVPNForceSwitch - принудительное переключение VPN func HandleVPNForceSwitch(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - VPN string `json:"vpn"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - sendJSONError(w, "Invalid request body", http.StatusBadRequest) - return - } - - manager := vpn.GetVPNManager() - - switch req.VPN { - case "openvpn": - manager.SetDefaultVPN("openvpn") - manager.StartDefaultVPN() - case "wireguard": - manager.SetDefaultVPN("wireguard") - manager.StartDefaultVPN() - default: - sendJSONError(w, "Invalid VPN type", http.StatusBadRequest) - return - } - - sendJSONResponse(w, http.StatusOK, map[string]string{ - "status": "switched", - "vpn": req.VPN, - }) + sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) } -// HandleNetworkInterfaces - получение списка сетевых интерфейсов func HandleNetworkInterfaces(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - nm := network.GetManager() interfaces, err := nm.GetInterfaces() if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, interfaces) } -// HandleInterfaceControl - управление интерфейсом (up/down) func HandleInterfaceControl(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Interface string `json:"interface"` Action string `json:"action"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - nm := network.GetManager() var err error - switch req.Action { case "up": err = nm.SetInterfaceUp(req.Interface) @@ -2004,173 +2066,112 @@ func HandleInterfaceControl(w http.ResponseWriter, r *http.Request) { sendJSONError(w, "Invalid action", http.StatusBadRequest) return } - if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) } -// HandleWiFiScan - сканирование WiFi сетей func HandleWiFiScan(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - iface := r.URL.Query().Get("interface") if iface == "" { sendJSONError(w, "Interface required", http.StatusBadRequest) return } - - // Для ARM используем контекст с таймаутом - nm := network.GetManager() networks, err := nm.ScanWiFiNetworks(iface) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, networks) } -// HandleWiFiConnect - подключение к WiFi func HandleWiFiConnect(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Interface string `json:"interface"` SSID string `json:"ssid"` Password string `json:"password"` Country string `json:"country"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - nm := network.GetManager() err := nm.ConnectToWiFi(req.Interface, req.SSID, req.Password, req.Country) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "connected"}) } -// HandleWiFiDisconnect - отключение WiFi func HandleWiFiDisconnect(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Interface string `json:"interface"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - nm := network.GetManager() err := nm.DisconnectWiFi(req.Interface) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "disconnected"}) } -// HandleWiFiStatus - статус WiFi func HandleWiFiStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - iface := r.URL.Query().Get("interface") if iface == "" { sendJSONError(w, "Interface required", http.StatusBadRequest) return } - nm := network.GetManager() connected, ssid := nm.GetWiFiStatus(iface) - sendJSONResponse(w, http.StatusOK, map[string]interface{}{ "connected": connected, "ssid": ssid, }) } -// HandleIPConfig - получение конфигурации IP func HandleIPConfig(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - iface := r.URL.Query().Get("interface") if iface == "" { sendJSONError(w, "Interface required", http.StatusBadRequest) return } - nm := network.GetManager() config, err := nm.GetIPConfig(iface) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, config) } -// HandleSetDHCP - установка DHCP func HandleSetDHCP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Interface string `json:"interface"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - nm := network.GetManager() err := nm.SetDHCP(req.Interface) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "dhcp_set"}) } -// HandleSetStaticIP - установка статического IP func HandleSetStaticIP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Interface string `json:"interface"` IP string `json:"ip"` @@ -2178,63 +2179,47 @@ func HandleSetStaticIP(w http.ResponseWriter, r *http.Request) { Gateway string `json:"gateway"` DNS string `json:"dns"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - if req.IP == "" { sendJSONError(w, "IP address required", http.StatusBadRequest) return } - nm := network.GetManager() err := nm.SetStaticIP(req.Interface, req.IP, req.Netmask, req.Gateway, req.DNS) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "static_ip_set"}) } -// HandleRenewDHCP - обновление DHCP func HandleRenewDHCP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Interface string `json:"interface"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - nm := network.GetManager() err := nm.RenewDHCP(req.Interface) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "dhcp_renewed"}) } -// HandleTimeSettings - получение и установка настроек времени func HandleTimeSettings(w http.ResponseWriter, r *http.Request) { db := database.GetDB() - switch r.Method { case http.MethodGet: timeMode, _ := db.GetSetting("time_mode") ntpServer, _ := db.GetSetting("ntp_server") timezone, _ := db.GetSetting("timezone") - if timeMode == "" { timeMode = "ntp" } @@ -2244,10 +2229,8 @@ func HandleTimeSettings(w http.ResponseWriter, r *http.Request) { if timezone == "" { timezone = "Europe/Moscow" } - currentTime := time.Now() utcTime := time.Now().UTC() - config := map[string]interface{}{ "time_mode": timeMode, "ntp_server": ntpServer, @@ -2257,7 +2240,6 @@ func HandleTimeSettings(w http.ResponseWriter, r *http.Request) { "utc_offset": getTimezoneOffset(), } sendJSONResponse(w, http.StatusOK, config) - case http.MethodPost: var req struct { TimeMode string `json:"time_mode"` @@ -2265,12 +2247,10 @@ func HandleTimeSettings(w http.ResponseWriter, r *http.Request) { Timezone string `json:"timezone"` DateTime string `json:"date_time"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - if req.TimeMode != "" { db.SetSetting("time_mode", req.TimeMode) } @@ -2280,54 +2260,45 @@ func HandleTimeSettings(w http.ResponseWriter, r *http.Request) { if req.Timezone != "" { db.SetSetting("timezone", req.Timezone) } - var err error if req.TimeMode == "ntp" { err = syncWithNTP(req.NTPServer) } else if req.DateTime != "" { err = setSystemTime(req.DateTime, req.Timezone) } - if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - if req.Timezone != "" { if err := setTimezone(req.Timezone); err != nil { log.Printf("Warning: failed to set timezone: %v", err) } } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) - default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } -// HandleTimeSync - принудительная синхронизация времени func HandleTimeSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - db := database.GetDB() ntpServer, _ := db.GetSetting("ntp_server") if ntpServer == "" { ntpServer = "pool.ntp.org" } - if err := syncWithNTP(ntpServer); err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "synced"}) } -// Helper functions +// Вспомогательные функции для времени func getTimezoneOffset() string { _, offset := time.Now().Zone() hours := offset / 3600 @@ -2339,109 +2310,80 @@ func getTimezoneOffset() string { func syncWithNTP(server string) error { log.Printf("Syncing time with NTP server: %s", server) - cmd := exec.Command("sudo", "ntpdate", "-u", server) output, err := cmd.CombinedOutput() if err != nil { log.Printf("ntpdate failed: %v, output: %s", err, output) - cmd2 := exec.Command("sudo", "timedatectl", "set-ntp", "true") if err2 := cmd2.Run(); err2 != nil { return fmt.Errorf("failed to sync time: %v", err) } } - log.Printf("Time synced successfully") return nil } func setSystemTime(dateTime string, timezone string) error { log.Printf("Setting system time: %s with timezone: %s", dateTime, timezone) - loc, err := time.LoadLocation(timezone) if err != nil { loc = time.Local } - t, err := time.ParseInLocation("2006-01-02 15:04:05", dateTime, loc) if err != nil { return fmt.Errorf("invalid datetime format: %v", err) } - utcTime := t.UTC() - dateStr := utcTime.Format("2006-01-02") timeStr := utcTime.Format("15:04:05") - cmd := exec.Command("sudo", "date", "-u", "-s", dateStr+" "+timeStr) if output, err := cmd.CombinedOutput(); err != nil { log.Printf("Failed to set date: %v, output: %s", err, output) return fmt.Errorf("failed to set system time: %v", err) } - exec.Command("sudo", "hwclock", "--systohc", "--utc").Run() - log.Printf("System time set to UTC: %s", utcTime.Format("2006-01-02 15:04:05")) return nil } func setTimezone(timezone string) error { log.Printf("Setting timezone: %s", timezone) - zoneFile := "/usr/share/zoneinfo/" + timezone if _, err := os.Stat(zoneFile); err != nil { return fmt.Errorf("timezone not found: %s", timezone) } - cmd := exec.Command("sudo", "timedatectl", "set-timezone", timezone) if output, err := cmd.CombinedOutput(); err != nil { log.Printf("timedatectl failed: %v, output: %s", err, output) exec.Command("sudo", "ln", "-sf", zoneFile, "/etc/localtime").Run() } - return nil } -// HandleGetDisks - получение списка дисков func HandleGetDisks(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - dm := network.GetDiskManager() disks, err := dm.GetDisks() if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, disks) } -// HandleMountDisk - монтирование диска func HandleMountDisk(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Device string `json:"device"` MountPoint string `json:"mount_point"` SaveConfig bool `json:"save_config"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - if req.Device == "" || req.MountPoint == "" { sendJSONError(w, "Device and mount point required", http.StatusBadRequest) return } - dm := network.GetDiskManager() var err error if req.SaveConfig { @@ -2449,126 +2391,87 @@ func HandleMountDisk(w http.ResponseWriter, r *http.Request) { } else { err = dm.MountDisk(req.Device, req.MountPoint) } - if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "mounted"}) } -// HandleUnmountDisk - отмонтирование диска func HandleUnmountDisk(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { MountPoint string `json:"mount_point"` Device string `json:"device"` RemoveConfig bool `json:"remove_config"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - if req.MountPoint == "" { sendJSONError(w, "Mount point required", http.StatusBadRequest) return } - dm := network.GetDiskManager() err := dm.UnmountDisk(req.MountPoint, req.RemoveConfig, req.Device) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "unmounted"}) } -// HandleFormatDisk - форматирование диска func HandleFormatDisk(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { Device string `json:"device"` Filesystem string `json:"filesystem"` Label string `json:"label"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - if req.Device == "" { sendJSONError(w, "Device required", http.StatusBadRequest) return } - if req.Filesystem == "" { req.Filesystem = "ext4" } - dm := network.GetDiskManager() err := dm.FormatDisk(req.Device, req.Filesystem, req.Label) if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "formatted"}) } -// HandleGetMountConfigs - получение сохраненных настроек монтирования func HandleGetMountConfigs(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - dm := network.GetDiskManager() configs, err := dm.GetAllMountConfigsFromDB() if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, configs) } -// HandleAutoMount - принудительный запуск автомонтирования func HandleAutoMount(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - dm := network.GetDiskManager() err := dm.AutoMountDisks() if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "auto_mount_completed"}) } -// HandleStartVideoMix - запуск смешанной записи func HandleStartVideoMix(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { ChannelID string `json:"channel_id"` VideoDevice string `json:"video_device"` @@ -2579,73 +2482,51 @@ func HandleStartVideoMix(w http.ResponseWriter, r *http.Request) { AudioOffset int `json:"audio_offset"` AutoRestart bool `json:"auto_restart"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - ch, exists := RecorderInstance.GetChannel(req.ChannelID) if !exists { sendJSONError(w, "Channel not found", http.StatusNotFound) return } - if !ch.IsRecording { sendJSONError(w, "Audio recording is not active", http.StatusBadRequest) return } - mixer := recorder.GetVideoMixer() err := mixer.StartMixRecording(req.ChannelID, req.VideoDevice, ch, req.Width, req.Height, req.Framerate, req.Bitrate, req.AudioOffset, req.AutoRestart) - if err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "started"}) } -// HandleStopVideoMix - остановка смешанной записи func HandleStopVideoMix(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { ChannelID string `json:"channel_id"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, "Invalid request body", http.StatusBadRequest) return } - mixer := recorder.GetVideoMixer() if err := mixer.StopMixRecording(req.ChannelID); err != nil { sendJSONError(w, err.Error(), http.StatusInternalServerError) return } - sendJSONResponse(w, http.StatusOK, map[string]string{"status": "stopped"}) } -// HandleVideoMixStatus - статус смешанной записи func HandleVideoMixStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - channelID := r.URL.Query().Get("channel") if channelID == "" { sendJSONError(w, "Channel ID required", http.StatusBadRequest) return } - mixer := recorder.GetVideoMixer() status := mixer.GetMixStatus(channelID) sendJSONResponse(w, http.StatusOK, status)