package main import ( "context" "errors" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "strings" "sync" "syscall" "time" ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/config" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" "git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/service" "github.com/joho/godotenv" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/prometheus/client_golang/prometheus/promhttp" "git.vakhrushev.me/av/transcriber/internal/clock" ) func main() { // Создаем структурированный логгер logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) slog.SetDefault(logger) // Parse command line flags configPath := flag.String("c", "config.toml", "Path to config file") flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)") flag.Parse() // Load configuration cfg, err := config.LoadConfig(*configPath) if err != nil { logger.Error("Unable to load configuration", "config_path", *configPath, "error", err) os.Exit(1) } else { logger.Info("Configuration loaded successfully", "config_path", *configPath) } // Незаполненный вход роняет старт: подняться с молча выключенным входом // значит остаться открытым наружу, и узнать об этом было бы неоткуда. if err := cfg.Auth.Validate(); err != nil { logger.Error("Unable to start with incomplete login settings", "error", err) os.Exit(1) } // Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а // отрицательное число и нулевой предел простоя — опечатка, и подниматься с // ней значит остановить всякую запись первым же захватом. if err := cfg.Pipeline.Validate(); err != nil { logger.Error("Unable to start with incorrect pipeline settings", "error", err) os.Exit(1) } // Загружаем переменные окружения из .env файла if err := godotenv.Load(); err != nil { logger.Warn("Warning: .env file not found, using system environment variables") } // Хранилище поднимается библиотекой, а не её набором команд: разбор флагов // и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет // непринятые шаги прежде, чем поднять сервер. storage, err := pbrepo.New(cfg.Storage.DataDir) if err != nil { logger.Error("Failed to open storage", "error", err) os.Exit(1) } defer func() { if err := storage.ResetBootstrapState(); err != nil { logger.Error("Failed to close storage", "error", err) } }() pbrepo.BindPanelRules(storage) // Создаем репозитории recordRepo := pbrepo.NewAudioRecordRepository(storage) repos := service.Repositories{ Records: recordRepo, Files: pbrepo.NewFileRepository(storage), Texts: pbrepo.NewTextRepository(storage), Structures: pbrepo.NewStructureRepository(storage), Recognitions: pbrepo.NewRecognitionRepository(storage), Events: pbrepo.NewRecordEventRepository(storage), } // Создаем адаптеры metaviewer := ffmpegmv.NewFfmpegMetaViewer() converter := ffmpegconv.NewFfmpegConverter() recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{ Region: cfg.Yandex.ObjStorageRegion, AccessKey: cfg.Yandex.ObjStorageAccessKey, SecretKey: cfg.Yandex.ObjStorageSecretKey, BucketName: cfg.Yandex.ObjStorageBucketName, Endpoint: cfg.Yandex.ObjStorageEndpoint, ApiKey: cfg.Yandex.SpeechKitAPIKey, FolderID: cfg.Yandex.FolderID, }) if err != nil { logger.Error("failed to create audio recognizer", "error", err) os.Exit(1) } // Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт // в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь // при повторном закрытии, то есть запись говорит о нашей ошибке, а не о // недоступности Yandex. defer func() { if err := recognizer.Close(); err != nil { logger.Error("failed to close audio recognizer", "error", err) } }() // Создаем сервисы transcribeService := service.NewTranscribeService( repos, metaviewer, converter, recognizer, cfg.Pipeline.StuckLimits(), logger, ) // Создаем контекст для graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Создаем WaitGroup для ожидания завершения всех воркеров var wg sync.WaitGroup // Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу // самой записи. Число приходит настройкой, ноль — законное значение. pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger) wg.Add(1) go func() { defer wg.Done() pool.Start(ctx) }() // Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки // убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка, // а признак существует ради того дня, когда входов снова станет больше. metrics.IntakeUpGauge.WithLabelValues("http").Set(1) // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // и второму серверу на нём взяться неоткуда. appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger) authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{ AuthURL: cfg.Auth.AuthURL, RedirectURL: cfg.Auth.RedirectURL, ClientID: cfg.Auth.ClientID, SecureCookie: cfg.Auth.SecureCookie, }, logger) // Сервер приезжает каналом, а не общей переменной: хук исполняется в // горутине сервера, а читает его горутина остановки, и связи «произошло // раньше» между ними иначе нет. srvCh := make(chan *http.Server, 1) storage.OnServe().BindFunc(func(se *core.ServeEvent) error { // Шесть часов записи по медленному каналу переживают любой фиксированный // таймаут чтения, а умолчание хранилища — пять минут. Стойкость к // целенаправленной нагрузке объявлена вне модели угроз проекта. se.Server.ReadTimeout = 0 srvCh <- se.Server // Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл // `sloggin`, а хранилище пишет запросы в свою таблицу, которой в // журнале контейнера не видно. Поля — те, что просит конвенция. se.Router.BindFunc(func(e *core.RequestEvent) error { start := clock.Start() err := e.Next() level := slog.LevelInfo if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" { // Опрос здоровья и метрик идёт постоянно и полезного не несёт. level = slog.LevelDebug } logger.Log(e.Request.Context(), level, "Incoming request", "http.method", e.Request.Method, "http.route", journalRoute(e.Request.URL.Path), "http.status_code", e.Status(), "duration_ms", time.Since(start).Milliseconds(), "transport", "http") return err }) // Настройки провайдера приводятся к конфигу при каждом подъёме: // применённый шаг схемы не переписывается, и секрет, положенный // однажды шагом, не пережил бы ротации. if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{ AuthURL: cfg.Auth.AuthURL, TokenURL: cfg.Auth.TokenURL, UserInfoURL: cfg.Auth.UserInfoURL, ClientID: cfg.Auth.ClientID, ClientSecret: cfg.Auth.ClientSecret, }); err != nil { return fmt.Errorf("failed to apply provider settings: %w", err) } // Своё правило ограничителя частоты под корень приложения. Правило // хранилища настроено на его собственный корень и наших адресов больше не // покрывает: вместе с переездом приложения ограничитель перестал бы // существовать для него вовсе, и заметить это было бы нечем. if err := httpcontroller.ApplyAppRateLimit(storage); err != nil { return fmt.Errorf("failed to apply app rate limit: %w", err) } authHandler.Register(se.Router) appHandler.Register(se.Router) se.Router.GET("/health", func(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]string{ "status": "ok", "message": "Transcriber service is running", }) }) se.Router.GET("/metrics", func(e *core.RequestEvent) error { promhttp.Handler().ServeHTTP(e.Response, e.Request) return nil }) return se.Next() }) // Запускаем HTTP сервер в отдельной горутине serveErr := make(chan error, 1) wg.Add(1) go func() { defer wg.Done() logger.Info("Starting HTTP server", "port", cfg.Server.Port) err := apis.Serve(storage, apis.ServeConfig{ HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port), ShowStartBanner: false, }) if err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("HTTP server error", "error", err) serveErr <- err } }() // Настраиваем обработку сигналов для graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) logger.Info("Transcriber service started", "pipeline_workers", pool.Size()) logger.Info("Press Ctrl+C to stop...") // Ждем сигнал завершения либо отказ сервера select { case <-sigChan: logger.Info("Received shutdown signal, initiating graceful shutdown...") case <-serveErr: logger.Error("HTTP server stopped unexpectedly, shutting down") } // Создаем контекст с таймаутом для graceful shutdown HTTP сервера shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) defer shutdownCancel() // Останавливаем HTTP сервер select { case srv := <-srvCh: logger.Info("Shutting down HTTP server...") if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("HTTP server forced to shutdown", "error", err) } else { logger.Info("HTTP server stopped gracefully") } default: logger.Info("HTTP server was not started, nothing to shut down") } // Отменяем контекст для остановки воркеров cancel() // Создаем канал для уведомления о завершении всех воркеров done := make(chan struct{}) go func() { wg.Wait() close(done) }() // Ждем завершения всех воркеров или таймаута select { case <-done: logger.Info("All workers stopped gracefully") case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second): logger.Warn("Timeout reached, forcing shutdown") } logger.Info("Transcriber service stopped") } // filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний // сегмент такого пути и есть имя файла в хранилище. const filesPathPrefix = "/api/files/" // journalRoute готовит путь запроса к записи в журнал. // // Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя — // последняя часть ссылки `/api/files/...`, и строка журнала вместе с // идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь // всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в // собранные логи при каждом скачивании записи. // // Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не // теряется. func journalRoute(path string) string { if !strings.HasPrefix(path, filesPathPrefix) { return path } cut := strings.LastIndex(path, "/") if cut < len(filesPathPrefix) { return path } return path[:cut+1] + "<имя>" }