Compare commits

..

23 Commits

Author SHA1 Message Date
822e1680fb Split long messages when send to telegram 2025-08-17 15:53:47 +03:00
12b16b3749 More granular error handling
+ task queue refactoring
2025-08-17 15:26:59 +03:00
8eddab4455 Small refacroting of arg positions 2025-08-16 11:12:36 +03:00
3b71b190a0 Add error logging 2025-08-16 11:05:18 +03:00
946a1ea151 Embed migrations 2025-08-14 15:01:57 +03:00
7b4a1462e6 Fix alpine build 2025-08-14 14:48:53 +03:00
a1c394ba89 Fix deploy and storage path 2025-08-14 14:26:38 +03:00
df069a9aa1 Add docker image build scripts 2025-08-14 14:09:25 +03:00
aae83db2ea Simplify config and add user white list 2025-08-14 12:25:58 +03:00
137da5a893 Replace env vars with toml config 2025-08-14 12:01:29 +03:00
121585f807 Improve logs 2025-08-14 11:08:31 +03:00
85db17b131 Fix tg bot graceful shutdown 2025-08-14 10:46:06 +03:00
a284e3ef29 Add basic telegram bot 2025-08-14 09:56:31 +03:00
8fad4c5033 Add secret scans on commit 2025-08-13 17:48:32 +03:00
ad886ea985 Add slog gin logger 2025-08-13 17:40:46 +03:00
9dd5f47010 Change logger to slog 2025-08-13 12:56:09 +03:00
d957800e18 Add metrics for audio duration 2025-08-13 12:18:51 +03:00
900c7ecb51 Simplify workers and fix metrics 2025-08-13 11:37:05 +03:00
eb0dea6113 Add prometheus metrics 2025-08-13 11:17:35 +03:00
22cbaf0bca Switch file to io stream in recognizer interface 2025-08-13 09:40:30 +03:00
bab563519c Change adapters structure 2025-08-13 09:23:30 +03:00
f6b5e835a4 Replace all infra services with interfaces 2025-08-13 09:20:45 +03:00
3f31bd5ff2 Dependency inversion for s3 service 2025-08-12 15:05:46 +03:00
34 changed files with 1940 additions and 454 deletions

View File

@@ -1,21 +0,0 @@
# AWS S3 Configuration
# Регион AWS (например: us-east-1, eu-west-1)
AWS_REGION=us-east-1
# AWS Access Key ID (получить в AWS Console)
AWS_ACCESS_KEY_ID=your_access_key_id
# AWS Secret Access Key (получить в AWS Console)
AWS_SECRET_ACCESS_KEY=your_secret_access_key
# Имя S3 bucket для загрузки файлов
S3_BUCKET_NAME=your_bucket_name
# Кастомный endpoint для S3 (оставить пустым для AWS S3, заполнить для MinIO или других S3-совместимых сервисов)
S3_ENDPOINT=
# Yandex Cloud Speech-to-Text Configuration
# API ключ для доступа к Yandex Cloud (получить в консоли Yandex Cloud)
YANDEX_CLOUD_API_KEY=your_api_key_here
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
YANDEX_CLOUD_FOLDER_ID=your_folder_id_here

6
.gitignore vendored
View File

@@ -48,10 +48,8 @@ Thumbs.db
# Log files
*.log
# Environment files
.env
.env.local
.env.*.local
# Config files
config.toml
# Sample and test audio files
*.m4a

72
Dockerfile Normal file
View File

@@ -0,0 +1,72 @@
# Build stage
FROM docker.io/library/golang:1.24-alpine AS build-env
# Install build dependencies
RUN apk --no-cache add \
build-base \
sqlite-dev \
&& rm -rf /var/cache/apk/*
# Set up the working directory
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN go build -o transcriber .
# ----------------
# Production stage
# ----------------
FROM docker.io/library/alpine:latest
LABEL maintainer="anton@vakhrushev.me"
# Install runtime dependencies
RUN apk --no-cache add \
ca-certificates \
ffmpeg \
&& rm -rf /var/cache/apk/*
# Create user and group
RUN addgroup -S -g 1000 transcriber && \
adduser -S -H -D \
-h /data/transcriber \
-s /bin/sh \
-u 1000 \
-G transcriber \
transcriber
# Set environment variables
ENV USER=transcriber
# Create necessary directories
RUN mkdir -p /data && chown -R transcriber:transcriber /data
RUN mkdir -p /config && chown -R transcriber:transcriber /config
# Set working directory
WORKDIR /app
# Copy the binary from build stage
COPY --from=build-env /app/transcriber .
# Copy entrypoint script
COPY docker/entrypoint.sh /usr/bin/entrypoint
RUN chmod 755 /usr/bin/entrypoint
# Set user
USER transcriber
EXPOSE 8080
# Set entrypoint and default command
# ENTRYPOINT ["/usr/bin/entrypoint"]
CMD ["./transcriber"]

28
Taskfile.yml Normal file
View File

@@ -0,0 +1,28 @@
# https://taskfile.dev
version: '3'
vars:
GREETING: Hello, World!
tasks:
deploy:
vars:
COMMIT_HASH:
sh: git rev-parse --short HEAD
TIMESTAMP:
sh: date +%s
DOCKER_IMAGE: transcriber:{{.COMMIT_HASH}}-{{.TIMESTAMP}}
cmds:
- docker build --pull --file Dockerfile --tag {{.DOCKER_IMAGE}} .
- task: deploy-with-ansible
vars:
DOCKER_IMAGE: '{{.DOCKER_IMAGE}}'
deploy-with-ansible:
internal: true
requires:
vars: [DOCKER_IMAGE]
dir: '/home/av/projects/private/pet-project-server'
cmd: ansible-playbook -i production.yml playbook-transcriber.yml --extra-vars 'transcriber_image={{.DOCKER_IMAGE}}'

45
config.dist.toml Normal file
View File

@@ -0,0 +1,45 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Database configuration
[database]
path = "data/transcriber.db"
# File storage configuration
[storage]
path = "data/files"
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
folder_id = "your_folder_id_here"
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
speech_kit_api_key = "your_speech_kit_api_key_here"
# Object Storage (S3) configuration
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_access_key_id = "your_access_key_id"
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_secret_access_key = "your_secret_access_key"
# Имя бакета в Object Storage
object_storage_bucket_name = "your_bucket_name"
# Регион Object Storage
object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Telegram Bot Configuration
[telegram]
# Токен Telegram бота (получить у @BotFather в Telegram)
bot_token = "your_telegram_bot_token_here"
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10

41
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/sh
# Protect against buggy runc in docker <20.10.6 causing problems in with Alpine >= 3.14
if [ ! -x /bin/sh ]; then
echo "Executable test for /bin/sh failed. Your Docker version is too old to run Alpine 3.14+ and Gitea. You must upgrade Docker.";
exit 1;
fi
if [ "${USER}" != "transcriber" ]; then
# Rename user
sed -i -e "s/^transcriber\:/${USER}\:/g" /etc/passwd
fi
if [ -z "${USER_GID}" ]; then
USER_GID="$(id -g ${USER})"
fi
if [ -z "${USER_UID}" ]; then
USER_UID="$(id -u ${USER})"
fi
# Change GID for USER?
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
fi
# Change UID for USER?
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
fi
for FOLDER in /data /config; do
mkdir -p ${FOLDER}
done
if [ $# -gt 0 ]; then
exec "$@"
else
exec ./transcriber
fi

30
go.mod
View File

@@ -3,6 +3,7 @@ module git.vakhrushev.me/av/transcriber
go 1.24.5
require (
github.com/BurntSushi/toml v1.5.0
github.com/aws/aws-sdk-go-v2 v1.37.2
github.com/aws/aws-sdk-go-v2/config v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
@@ -10,10 +11,13 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
github.com/doug-martin/goqu/v9 v9.19.0
github.com/gin-gonic/gin v1.10.1
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.17
github.com/pressly/goose/v3 v3.15.1
github.com/mattn/go-sqlite3 v1.14.31
github.com/pressly/goose/v3 v3.24.3
github.com/prometheus/client_golang v1.23.0
github.com/samber/slog-gin v1.15.1
github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.74.2
@@ -34,30 +38,42 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
github.com/aws/smithy-go v1.22.5 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect

110
go.sum
View File

@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
@@ -38,10 +40,14 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
@@ -54,8 +60,8 @@ github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSv
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
@@ -70,11 +76,13 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -87,35 +95,60 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM=
github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.15.1 h1:dKaJ1SdLvS/+HtS8PzFT0KBEtICC1jewLXM+b3emlv8=
github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -148,6 +181,10 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -156,8 +193,8 @@ golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
@@ -172,8 +209,6 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
@@ -182,30 +217,19 @@ google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,72 @@
package ffmpeg
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
const ffprobeExecutable = "ffprobe"
type FfmpegMetaViewer struct {
}
// ffprobeOutput представляет структуру JSON-ответа от ffprobe
type ffprobeOutput struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
func NewFfmpegMetaViewer() *FfmpegMetaViewer {
return &FfmpegMetaViewer{}
}
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
// Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, fmt.Errorf("input file does not exist: %s", src)
}
// Проверяем, что ffprobe доступен в системе
if _, err := exec.LookPath(ffprobeExecutable); err != nil {
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
}
// Создаем команду ffprobe для получения метаданных
cmd := exec.Command(ffprobeExecutable,
"-v", "quiet", // тихий режим (без лишнего вывода)
"-print_format", "json", // вывод в формате JSON
"-show_format", // показать информацию о формате
src, // входной файл
)
// Выполняем команду и получаем вывод
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ffprobe execution failed: %w", err)
}
// Парсим JSON-ответ
var probeResult ffprobeOutput
if err := json.Unmarshal(output, &probeResult); err != nil {
return nil, fmt.Errorf("failed to parse ffprobe output: %w", err)
}
// Конвертируем длительность из строки в секунды
durationFloat, err := strconv.ParseFloat(probeResult.Format.Duration, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse duration: %w", err)
}
// Округляем до целых секунд
durationSeconds := int(durationFloat + 0.5) // +0.5 для правильного округления
return &contract.AudioInfo{
Seconds: durationSeconds,
}, nil
}

View File

@@ -0,0 +1,22 @@
package recognizer
import (
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/google/uuid"
)
type MemoryAudioRecognizer struct{}
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
return uuid.NewString(), nil
}
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
return entity.NewCompletedResult(), nil
}

View File

@@ -0,0 +1,94 @@
package yandex
import (
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type YandexAudioRecognizerConfig struct {
// s3
Region string
AccessKey string
SecretKey string
BucketName string
Endpoint string
// speech kit
ApiKey string
FolderID string
}
type YandexAudioRecognizerService struct {
s3Sevice *yandexS3Service
sttService *speechKitService
}
func NewYandexAudioRecognizerService(cfg YandexAudioRecognizerConfig) (*YandexAudioRecognizerService, error) {
s3, err := newYandexS3Service(s3Config{
Region: cfg.Region,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
BucketName: cfg.BucketName,
Endpoint: cfg.Endpoint,
})
if err != nil {
return nil, err
}
stt, err := newSpeechKitService(speechKitConfig{
ApiKey: cfg.ApiKey,
FolderID: cfg.FolderID,
})
if err != nil {
return nil, err
}
return &YandexAudioRecognizerService{
s3Sevice: s3,
sttService: stt,
}, nil
}
func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close()
}
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
err := s.s3Sevice.uploadFile(file, fileName)
if err != nil {
return "", err
}
uri := s.s3Sevice.fileUrl(fileName)
opId, err := s.sttService.recognizeFileFromS3(uri)
if err != nil {
return "", err
}
return opId, nil
}
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
return s.sttService.getRecognitionText(operationID)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(operationID)
if err != nil {
return nil, err
}
if !operation.Done {
return entity.NewInProgressResult(), nil
}
if opErr := operation.GetError(); opErr != nil {
errorText := fmt.Sprintf("operation failed: code %d, message: %s", opErr.Code, opErr.Message)
return entity.NewFailedResult(errorText), nil
}
return entity.NewCompletedResult(), nil
}

View File

@@ -0,0 +1,84 @@
package yandex
import (
"context"
"fmt"
"io"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type s3Config struct {
Region string
AccessKey string
SecretKey string
BucketName string
Endpoint string
}
type yandexS3Service struct {
client *s3.Client
uploader *manager.Uploader
bucketName string
endpoint string
}
func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
if cfg.Region == "" || cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.BucketName == "" {
return nil, fmt.Errorf("missing required S3 configuration parameters")
}
// Создаем конфигурацию
awsCfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(cfg.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
// Создаем клиент S3
var client *s3.Client
if cfg.Endpoint != "" {
// Кастомный endpoint (например, для MinIO)
client = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = true
})
} else {
// Стандартный AWS S3
client = s3.NewFromConfig(awsCfg)
}
uploader := manager.NewUploader(client)
return &yandexS3Service{
client: client,
uploader: uploader,
bucketName: cfg.BucketName,
endpoint: cfg.Endpoint,
}, nil
}
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(fileName),
Body: file,
})
if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err)
}
return nil
}
func (s *yandexS3Service) fileUrl(fileName string) string {
endpoint := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
}

View File

@@ -1,9 +1,8 @@
package speechkit
package yandex
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/grpc"
@@ -21,7 +20,12 @@ const (
RecognitionModel = "deferred-general"
)
type SpeechKitService struct {
type speechKitConfig struct {
ApiKey string
FolderID string
}
type speechKitService struct {
sttConn *grpc.ClientConn
opConn *grpc.ClientConn
sttClient stt.AsyncRecognizerClient
@@ -30,9 +34,9 @@ type SpeechKitService struct {
folderID string
}
func NewSpeechKitService() (*SpeechKitService, error) {
apiKey := os.Getenv("YANDEX_CLOUD_API_KEY")
folderID := os.Getenv("YANDEX_CLOUD_FOLDER_ID")
func newSpeechKitService(cfg speechKitConfig) (*speechKitService, error) {
apiKey := cfg.ApiKey
folderID := cfg.FolderID
if apiKey == "" || folderID == "" {
return nil, fmt.Errorf("missing required Yandex Cloud environment variables")
@@ -55,7 +59,7 @@ func NewSpeechKitService() (*SpeechKitService, error) {
sttClient := stt.NewAsyncRecognizerClient(sttConn)
opClient := operation.NewOperationServiceClient(opConn)
return &SpeechKitService{
return &speechKitService{
sttConn: sttConn,
opConn: opConn,
sttClient: sttClient,
@@ -65,7 +69,7 @@ func NewSpeechKitService() (*SpeechKitService, error) {
}, nil
}
func (s *SpeechKitService) Close() error {
func (s *speechKitService) Close() error {
var err1, err2 error
if s.sttConn != nil {
err1 = s.sttConn.Close()
@@ -79,8 +83,8 @@ func (s *SpeechKitService) Close() error {
return err2
}
// RecognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *SpeechKitService) RecognizeFileFromS3(s3URI string) (string, error) {
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
ctx := context.Background()
// Добавляем авторизацию и folder_id в контекст
@@ -123,7 +127,7 @@ func (s *SpeechKitService) RecognizeFileFromS3(s3URI string) (string, error) {
}
// GetRecognitionResult получает результат распознавания по ID операции
func (s *SpeechKitService) GetRecognitionText(operationID string) (string, error) {
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
ctx := context.Background()
// Добавляем авторизацию и folder_id в контекст
@@ -162,8 +166,8 @@ func (s *SpeechKitService) GetRecognitionText(operationID string) (string, error
return sb.String(), nil
}
// CheckOperationStatus проверяет статус операции распознавания
func (s *SpeechKitService) CheckOperationStatus(operationID string) (*operation.Operation, error) {
// checkOperationStatus проверяет статус операции распознавания
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)

View File

@@ -21,18 +21,21 @@ func NewTranscriptJobRepository(db *sql.DB, gq *goqu.Database) *TranscriptJobRep
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
record := goqu.Record{
"id": job.Id,
"state": job.State,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"created_at": job.CreatedAt,
"updated_at": job.UpdatedAt,
"id": job.Id,
"state": job.State,
"source": job.Source,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"tg_chat_id": job.TgChatId,
"tg_reply_message_id": job.TgReplyMessageId,
"created_at": job.CreatedAt,
"updated_at": job.UpdatedAt,
}
query := repo.gq.Insert("transcribe_jobs").Rows(record)
sql, args, err := query.ToSQL()
@@ -50,16 +53,19 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob) error {
record := goqu.Record{
"state": job.State,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"updated_at": job.UpdatedAt,
"state": job.State,
"source": job.Source,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"tg_chat_id": job.TgChatId,
"tg_reply_message_id": job.TgReplyMessageId,
"updated_at": job.UpdatedAt,
}
query := repo.gq.Update("transcribe_jobs").Set(record).Where(goqu.C("id").Eq(job.Id))
sql, args, err := query.ToSQL()
@@ -79,6 +85,7 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
query := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"source",
"file_id",
"is_error",
"error_text",
@@ -87,6 +94,8 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
"delay_time",
"recognition_op_id",
"transcription_text",
"tg_chat_id",
"tg_reply_message_id",
"created_at",
"updated_at",
).Where(goqu.C("id").Eq(id))
@@ -99,6 +108,7 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.Source,
&job.FileID,
&job.IsError,
&job.ErrorText,
@@ -107,6 +117,8 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
&job.DelayTime,
&job.RecognitionOpID,
&job.TranscriptionText,
&job.TgChatId,
&job.TgReplyMessageId,
&job.CreatedAt,
&job.UpdatedAt,
)
@@ -172,6 +184,7 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
selectQuery := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"source",
"file_id",
"is_error",
"error_text",
@@ -180,6 +193,8 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
"delay_time",
"recognition_op_id",
"transcription_text",
"tg_chat_id",
"tg_reply_message_id",
"created_at",
"updated_at",
).Where(goqu.C("acquisition_id").Eq(acquisitionId))
@@ -193,6 +208,7 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.Source,
&job.FileID,
&job.IsError,
&job.ErrorText,
@@ -201,6 +217,8 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
&job.DelayTime,
&job.RecognitionOpID,
&job.TranscriptionText,
&job.TgChatId,
&job.TgReplyMessageId,
&job.CreatedAt,
&job.UpdatedAt,
)

View File

@@ -0,0 +1,67 @@
package telegram
import (
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
TextLengthLimit = 4000
)
type TelegramMessageSender struct {
bot *tgbotapi.BotAPI
logger *slog.Logger
}
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, err
}
return &TelegramMessageSender{
bot: bot,
logger: logger,
}, nil
}
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
// If message is short enough, send it directly
if len([]rune(text)) <= TextLengthLimit {
return s.sendSingleMessage(text, chatId, replyToMessageId)
}
// Split long message into parts
parts := s.splitMessageByWords(text, TextLengthLimit)
// Send each part
for i, part := range parts {
var replyId *int
// Only use replyToMessageId for the first part
if i == 0 {
replyId = replyToMessageId
}
err := s.sendSingleMessage(part, chatId, replyId)
if err != nil {
return err
}
}
return nil
}
// sendSingleMessage sends a single message
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
resultMsg := tgbotapi.NewMessage(chatId, text)
if replyToMessageId != nil {
resultMsg.ReplyToMessageID = *replyToMessageId
}
_, err := s.bot.Send(resultMsg)
if err != nil {
s.logger.Error("Failed to send message to tg bot", "error", err)
return err
}
return nil
}

View File

@@ -0,0 +1,62 @@
package telegram
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
// splitting by words to avoid cutting words in the middle
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
var parts []string
// If text is already short enough, return as is
if len([]rune(text)) <= maxLen {
return []string{text}
}
runes := []rune(text)
for len(runes) > 0 {
// Determine the end position for this part
end := len(runes)
if end > maxLen {
end = maxLen
}
// Try to find a good split point (word boundary)
splitPoint := end
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
// Check if this is a good split point (after a space)
if runes[i] == ' ' {
splitPoint = i + 1 // Include the space in the previous part
break
}
}
// If we couldn't find a good split point, just split at maxLen
if splitPoint == end && end == maxLen {
// Check if we're in the middle of a word
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
// Try to find a split point going forward
for i := end; i < len(runes) && i < end+20; i++ {
if runes[i] == ' ' {
splitPoint = i
break
}
}
}
}
// If still no good split point, use the original end
if splitPoint > len(runes) {
splitPoint = len(runes)
}
// Add this part
parts = append(parts, string(runes[:splitPoint]))
// Move to the next part
if splitPoint >= len(runes) {
break
}
runes = runes[splitPoint:]
}
return parts
}

View File

@@ -0,0 +1,125 @@
package telegram
import (
"testing"
)
func TestTelegramMessageSender_splitMessageByWords(t *testing.T) {
sender := &TelegramMessageSender{}
tests := []struct {
name string
text string
maxLen int
expected []string
}{
{
name: "Short text should return as is",
text: "Привет мир",
maxLen: 25,
expected: []string{
"Привет мир",
},
},
{
name: "Text exactly at limit",
text: "Это тестовый текст который ровно соответствует лимиту",
maxLen: 35,
expected: []string{
"Это тестовый текст который ровно ",
"соответствует ",
"лимиту",
},
},
{
name: "Text with word boundaries",
text: "Это очень длинный текст для проверки работы функции разделения сообщения",
maxLen: 25,
expected: []string{
"Это очень длинный текст ",
"для проверки работы ",
"функции разделения ",
"сообщения",
},
},
{
name: "Text with long words",
text: "Этот текст содержит оченьдлинноеслово которое не должно быть разбито",
maxLen: 20,
expected: []string{
"Этот текст содержит ",
"оченьдлинноеслово ",
"которое не должно ",
"быть ",
"разбито",
},
},
{
name: "Text with multiple spaces",
text: "Этот текст имеет много пробелов",
maxLen: 20,
expected: []string{
"Этот текст ",
"имеет много ",
"пробелов",
},
},
{
name: "Text with Russian characters and punctuation",
text: "Привет! Как дела? Это тестовая строка для проверки работы функции.",
maxLen: 25,
expected: []string{
"Привет! Как дела? Это ",
"тестовая строка для ",
"проверки работы ",
"функции.",
},
},
{
name: "Single word longer than maxLen",
text: "Некотороедлинноеслово",
maxLen: 10,
expected: []string{
"Некотороед",
"линноеслов",
"о",
},
},
{
name: "Text with mixed Russian and English",
text: "Привет Hello мир World текст для проверки",
maxLen: 20,
expected: []string{
"Привет Hello мир ",
"World текст для ",
"проверки",
},
},
{
name: "Text with special characters",
text: "Тест с символами: @#$%^&*()_+-=[]{}|;':\",./<>?",
maxLen: 25,
expected: []string{
"Тест с символами: ",
"@#$%^&*()_+-=[]{}|;':\",./",
"<>?",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sender.splitMessageByWords(tt.text, tt.maxLen)
if len(result) != len(tt.expected) {
t.Errorf("splitMessageByWords() length = %d, want %d", len(result), len(tt.expected))
return
}
for i, expectedPart := range tt.expected {
if result[i] != expectedPart {
t.Errorf("splitMessageByWords() part %d = %q, want %q", i, result[i], expectedPart)
}
}
})
}
}

93
internal/config/config.go Normal file
View File

@@ -0,0 +1,93 @@
package config
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
type Config struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
Storage StorageConfig `toml:"storage"`
Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
}
type ServerConfig struct {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
UsersWhiteList []string `toml:"users_while_list"`
}
type DatabaseConfig struct {
Path string `toml:"path"`
}
type StorageConfig struct {
Path string `toml:"path"`
}
type YandexConfig struct {
FolderID string `toml:"folder_id"`
SpeechKitAPIKey string `toml:"speech_kit_api_key"`
ObjStorageAccessKey string `toml:"object_storage_access_key_id"`
ObjStorageSecretKey string `toml:"object_storage_secret_access_key"`
ObjStorageBucketName string `toml:"object_storage_bucket_name"`
ObjStorageRegion string `toml:"object_storage_region"`
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
type TelegramConfig struct {
BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"`
}
// DefaultConfig returns a Config with default values
func defaultConfig() *Config {
return &Config{
Server: ServerConfig{
Port: 8080,
ShutdownTimeout: 5,
ForceShutdownTimeout: 20,
},
Database: DatabaseConfig{
Path: "data/transcriber.db",
},
Storage: StorageConfig{
Path: "data/files",
},
Yandex: YandexConfig{
FolderID: "",
SpeechKitAPIKey: "",
ObjStorageAccessKey: "",
ObjStorageSecretKey: "",
ObjStorageBucketName: "",
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
},
}
}
// LoadConfig loads configuration from a TOML file
func LoadConfig(path string) (*Config, error) {
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found: %s", path)
}
config := defaultConfig()
// Load configuration from file
if _, err := toml.DecodeFile(path, &config); err != nil {
return nil, fmt.Errorf("failed to decode config file: %w", err)
}
return config, nil
}

View File

@@ -1,8 +1,29 @@
package contract
type ObjectStorage interface {
import (
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type AudioInfo struct {
Seconds int // Длина аудиофайла в секундах
}
type AudioMetaViewer interface {
GetInfo(src string) (*AudioInfo, error)
}
type AudioFileConverter interface {
Convert(src, dest string) error
}
type AudioRecognizer interface {
Recognize(file io.Reader, fileName string) (operationID string, err error)
GetRecognitionText(operationID string) (string, error)
CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error)
}
type TelegramMessageSender interface {
Send(text string, chatId int64, replyToMessageId *int) error
}

View File

@@ -10,3 +10,11 @@ type JobNotFoundError struct {
func (e *JobNotFoundError) Error() string {
return fmt.Sprintf("%s - %s", e.State, e.Message)
}
type NoopJobError struct {
State string
}
func (e *NoopJobError) Error() string {
return fmt.Sprintf("%s: no op job occur", e.State)
}

View File

@@ -10,8 +10,6 @@ import (
"github.com/gin-gonic/gin"
)
const baseStorageDir = "data/files"
type TranscribeHandler struct {
jobRepo contract.TranscriptJobRepository
trsService *service.TranscribeService
@@ -42,7 +40,7 @@ func (h *TranscribeHandler) CreateTranscribeJob(c *gin.Context) {
}
defer file.Close()
job, err := h.trsService.CreateTranscribeJob(file, header.Filename)
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
if err != nil {
log.Printf("Err: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcibe job"})

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -15,8 +16,10 @@ import (
"testing"
"time"
"git.vakhrushev.me/av/transcriber/internal/adapter/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/sqlite"
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"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
@@ -57,9 +60,25 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) {
fileRepo := sqlite.NewFileRepository(db, gq)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
converter := ffmpeg.NewFfmpegConverter()
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter()
recognizer := &recognizer.MemoryAudioRecognizer{}
trsService := service.NewTranscribeService(jobRepo, fileRepo, converter)
// Создаем тестовый логгер
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelError, // Только ошибки в тестах
}))
trsService := service.NewTranscribeService(
jobRepo,
fileRepo,
metaviewer,
converter,
recognizer,
&TestTgSender{},
"data/files",
logger,
)
handler := NewTranscribeHandler(jobRepo, trsService)
@@ -364,3 +383,9 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
assert.Equal(t, "Job not found", response["error"])
}
type TestTgSender struct{}
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
return nil
}

View File

@@ -0,0 +1,306 @@
package tg
import (
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strings"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type TelegramController struct {
// deps
transcribeService *service.TranscribeService
jobRepo contract.TranscriptJobRepository
logger *slog.Logger
// params
bot *tgbotapi.BotAPI
userWhiteList []string
updateTimeout int
}
type TelegramConfig struct {
BotToken string
UpdateTimeout int
UserWhiteList []string
}
func NewTelegramController(
config TelegramConfig,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*TelegramController, error) {
botToken := config.BotToken
if botToken == "" {
return nil, &EmptyBotTokenError{}
}
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, err
}
controller := &TelegramController{
bot: bot,
transcribeService: transcribeService,
jobRepo: jobRepo,
logger: logger,
updateTimeout: config.UpdateTimeout,
userWhiteList: config.UserWhiteList,
}
return controller, nil
}
func (c *TelegramController) Start() {
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
u := tgbotapi.NewUpdate(0)
u.Timeout = c.updateTimeout
updates := c.bot.GetUpdatesChan(u)
for update := range updates {
if update.Message == nil { // ignore any non-Message updates
continue
}
author := update.Message.From.String()
c.logger.Info("New incoming message", "author", author)
if !slices.Contains(c.userWhiteList, author) {
c.logger.Info("User is not in white list, reject", "author", author)
c.handleForbiddenUser(update.Message)
continue
}
// Handle commands
if update.Message.IsCommand() {
// Extract the command from the Message
switch update.Message.Command() {
case "start":
c.handleStartCommand(update.Message)
case "help":
c.handleHelpCommand(update.Message)
}
continue
}
// Handle audio messages and files
if update.Message.Audio != nil {
c.handleAudioMessage(update.Message)
} else if update.Message.Voice != nil {
c.handleVoiceMessage(update.Message)
} else if update.Message.Document != nil {
c.handleDocumentMessage(update.Message)
}
}
}
func (c *TelegramController) Stop() {
c.bot.StopReceivingUpdates()
}
func (c *TelegramController) send(chattable tgbotapi.Chattable) (tgbotapi.Message, error) {
msg, err := c.bot.Send(chattable)
if err != nil {
c.logger.Error("Failed to send message to tg bot", "error", err)
}
return msg, err
}
func (c *TelegramController) handleStartCommand(message *tgbotapi.Message) {
msg := tgbotapi.NewMessage(message.Chat.ID, "Привет! Я бот для расшифровки аудиосообщений. Отправь мне голосовое сообщение или аудиофайл, и я пришлю тебе текст.")
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleForbiddenUser(message *tgbotapi.Message) {
msg := tgbotapi.NewMessage(message.Chat.ID, "Извини, тебе нельзя пользоваться этим ботом. Обратись к владельцу бота.")
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
helpText := `Я бот для расшифровки аудиосообщений и аудиофайлов.
Просто отправь мне:
- Голосовое сообщение
- Аудиофайл (mp3, wav, ogg и др.)
Я пришлю тебе текст расшифровки.
Команды:
/start - Начало работы с ботом
/help - Показать эту справку`
msg := tgbotapi.NewMessage(message.Chat.ID, helpText)
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Audio.FileID)
if err != nil {
c.logger.Error("Failed to download audio file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Voice.FileID)
if err != nil {
c.logger.Error("Failed to download voice file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
// Проверяем, является ли документ аудиофайлом
if !c.isAudioDocument(message.Document) {
return
}
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Document.FileID)
if err != nil {
c.logger.Error("Failed to download document file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) downloadAudioFile(fileID string) (io.ReadCloser, string, error) {
// Получаем информацию о файле
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
// Скачиваем файл
fileURL := file.Link(c.bot.Token)
resp, err := http.Get(fileURL)
if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err)
}
// Получаем имя файла из URL
fileName := file.FilePath
if fileName == "" {
fileName = "audio.ogg"
}
return resp.Body, fileName, nil
}
func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
// Проверяем MIME-тип документа
if document.MimeType != "" {
return strings.HasPrefix(document.MimeType, "audio/") || strings.HasPrefix(document.MimeType, "video/")
}
// Проверяем расширение файла
audioExtensions := []string{".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".wma"}
filename := document.FileName
for _, ext := range audioExtensions {
if len(filename) >= len(ext) && strings.ToLower(filename[len(filename)-len(ext):]) == ext {
return true
}
}
return false
}
type EmptyBotTokenError struct{}
func (e *EmptyBotTokenError) Error() string {
return "telegram bot token is empty"
}

View File

@@ -2,10 +2,12 @@ package worker
import (
"context"
"log"
"log/slog"
"strconv"
"time"
"git.vakhrushev.me/av/transcriber/internal/service"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/metrics"
)
// Worker представляет базовый интерфейс для всех воркеров
@@ -14,121 +16,50 @@ type Worker interface {
Name() string
}
// ConversionWorker обрабатывает задачи конвертации
type ConversionWorker struct {
transcribeService *service.TranscribeService
type CallbackWorker struct {
name string
f func() error
logger *slog.Logger
}
func NewConversionWorker(transcribeService *service.TranscribeService) *ConversionWorker {
return &ConversionWorker{
transcribeService: transcribeService,
func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *CallbackWorker {
if logger == nil {
logger = slog.Default()
}
return &CallbackWorker{
name: name,
f: f,
logger: logger,
}
}
func (w *ConversionWorker) Name() string {
return "ConversionWorker"
func (w *CallbackWorker) Name() string {
return w.name
}
func (w *ConversionWorker) Start(ctx context.Context) {
log.Printf("%s started", w.Name())
func (w *CallbackWorker) Start(ctx context.Context) {
w.logger.Info("Worker started", "worker", w.Name())
for {
select {
case <-ctx.Done():
log.Printf("%s received shutdown signal", w.Name())
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
return
default:
err := w.transcribeService.FindAndRunConversionJob()
if err != nil {
log.Printf("%s error: %v", w.Name(), err)
err := w.f()
_, isNoop := err.(*contract.NoopJobError)
if !isNoop {
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
}
if err != nil && !isNoop {
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
}
// Ждем 1 секунду перед следующей итерацией
select {
case <-ctx.Done():
log.Printf("%s received shutdown signal during sleep", w.Name())
return
case <-time.After(1 * time.Second):
// Продолжаем работу
}
}
}
}
// TranscribeWorker обрабатывает задачи транскрипции
type TranscribeWorker struct {
transcribeService *service.TranscribeService
}
func NewTranscribeWorker(transcribeService *service.TranscribeService) *TranscribeWorker {
return &TranscribeWorker{
transcribeService: transcribeService,
}
}
func (w *TranscribeWorker) Name() string {
return "TranscribeWorker"
}
func (w *TranscribeWorker) Start(ctx context.Context) {
log.Printf("%s started", w.Name())
for {
select {
case <-ctx.Done():
log.Printf("%s received shutdown signal", w.Name())
return
default:
err := w.transcribeService.FindAndRunTranscribeJob()
if err != nil {
log.Printf("%s error: %v", w.Name(), err)
}
// Ждем 1 секунду перед следующей итерацией
select {
case <-ctx.Done():
log.Printf("%s received shutdown signal during sleep", w.Name())
return
case <-time.After(1 * time.Second):
// Продолжаем работу
}
}
}
}
// CheckWorker обрабатывает задачи проверки статуса распознавания
type CheckWorker struct {
transcribeService *service.TranscribeService
}
func NewCheckWorker(transcribeService *service.TranscribeService) *CheckWorker {
return &CheckWorker{
transcribeService: transcribeService,
}
}
func (w *CheckWorker) Name() string {
return "CheckWorker"
}
func (w *CheckWorker) Start(ctx context.Context) {
log.Printf("%s started", w.Name())
for {
select {
case <-ctx.Done():
log.Printf("%s received shutdown signal", w.Name())
return
default:
err := w.transcribeService.FindAndRunTranscribeCheckJob()
if err != nil {
log.Printf("%s error: %v", w.Name(), err)
}
// Ждем 1 секунду перед следующей итерацией
select {
case <-ctx.Done():
log.Printf("%s received shutdown signal during sleep", w.Name())
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
return
case <-time.After(1 * time.Second):
// Продолжаем работу

View File

@@ -7,6 +7,7 @@ import (
type TranscribeJob struct {
Id string
State string
Source string
FileID *string
IsError bool
ErrorText *string
@@ -15,6 +16,8 @@ type TranscribeJob struct {
DelayTime *time.Time
RecognitionOpID *string // ID операции распознавания в Yandex Cloud
TranscriptionText *string // Результат распознавания
TgChatId *int64 // Telegram: в какой чат отправить результат распознавания
TgReplyMessageId *int // Telegram: с каким сообщением связать результат распознавания
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -27,6 +30,14 @@ const (
StateFailed = "failed"
)
const (
SourceUnknown = "unknown"
SourceApi = "api"
SourceTelegram = "telegram"
)
// Переводит задачу в новое состояние, при этом очищает все
// служебные поля предыдущего состояния, как-то время задержки, информацию о воркере и тд
func (j *TranscribeJob) MoveToState(state string) {
j.State = state
j.DelayTime = nil

View File

@@ -0,0 +1,78 @@
package entity
// RecognitionStatus представляет статус операции транскрипции
type RecognitionStatus int
const (
// RecognitionStatusInProgress - операция в процессе выполнения
RecognitionStatusInProgress RecognitionStatus = iota
// RecognitionStatusCompleted - операция завершена успешно
RecognitionStatusCompleted
// RecognitionStatusFailed - операция завершена с ошибкой
RecognitionStatusFailed
)
// String возвращает строковое представление статуса
func (s RecognitionStatus) String() string {
switch s {
case RecognitionStatusInProgress:
return "in_progress"
case RecognitionStatusCompleted:
return "completed"
case RecognitionStatusFailed:
return "failed"
default:
return "unknown"
}
}
// RecognitionResult представляет результат операции транскрипции
type RecognitionResult struct {
Status RecognitionStatus
Error string // Текст ошибки (заполняется при StatusFailed)
}
// NewInProgressResult создает результат для операции в процессе выполнения
func NewInProgressResult() *RecognitionResult {
return &RecognitionResult{
Status: RecognitionStatusInProgress,
}
}
// NewCompletedResult создает результат для успешно завершенной операции
func NewCompletedResult() *RecognitionResult {
return &RecognitionResult{
Status: RecognitionStatusCompleted,
}
}
// NewFailedResult создает результат для операции, завершенной с ошибкой
func NewFailedResult(errorText string) *RecognitionResult {
return &RecognitionResult{
Status: RecognitionStatusFailed,
Error: errorText,
}
}
// IsInProgress проверяет, находится ли операция в процессе выполнения
func (r *RecognitionResult) IsInProgress() bool {
return r.Status == RecognitionStatusInProgress
}
// IsCompleted проверяет, завершена ли операция успешно
func (r *RecognitionResult) IsCompleted() bool {
return r.Status == RecognitionStatusCompleted
}
// IsFailed проверяет, завершена ли операция с ошибкой
func (r *RecognitionResult) IsFailed() bool {
return r.Status == RecognitionStatusFailed
}
// GetError возвращает текст ошибки (только для операций, завершенных с ошибкой)
func (r *RecognitionResult) GetError() string {
if r.IsFailed() {
return r.Error
}
return ""
}

View File

@@ -0,0 +1,56 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
WorkerJobCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "transcriber_worker_job_count",
Help: "Count of jobs handled by each worker",
},
[]string{"name", "error"},
)
// Размер принятых на обработку файлов (в байтах)
InputFileSizeHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "transcriber_input_file_size_bytes",
Help: "Size of input files received for processing",
Buckets: []float64{1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824}, // 1KB, 10KB, 100KB, 1MB, 10MB, 100MB, 1GB
},
[]string{"file_extension"},
)
// Время конвертации файлов (в секундах)
InputFileDurationHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "transcriber_input_file_duration_seconds",
Help: "Duration of input audio file",
Buckets: []float64{15, 30, 60, 120, 300, 600, 1200, 1800, 2400, 3000, 3600, 7200, 10800, 14400},
},
[]string{},
)
// Время конвертации файлов (в секундах)
ConversionDurationHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "transcriber_conversion_duration_seconds",
Help: "Time taken to convert audio files",
Buckets: []float64{0.1, 0.5, 1, 5, 10, 30, 60, 120, 300}, // 0.1s, 0.5s, 1s, 5s, 10s, 30s, 1m, 2m, 5m
},
[]string{"source_format", "target_format", "error"},
)
// Размер файла после конвертации (в байтах)
OutputFileSizeHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "transcriber_output_file_size_bytes",
Help: "Size of files after conversion",
Buckets: []float64{1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824}, // 1KB, 10KB, 100KB, 1MB, 10MB, 100MB, 1GB
},
[]string{"format"},
)
)

View File

@@ -1,87 +0,0 @@
package s3
import (
"context"
"fmt"
"os"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type S3Service struct {
client *s3.Client
uploader *manager.Uploader
bucketName string
}
func NewS3Service() (*S3Service, error) {
region := os.Getenv("AWS_REGION")
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
bucketName := os.Getenv("S3_BUCKET_NAME")
endpoint := os.Getenv("S3_ENDPOINT")
if region == "" || accessKey == "" || secretKey == "" || bucketName == "" {
return nil, fmt.Errorf("missing required S3 environment variables")
}
// Создаем конфигурацию
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
// Создаем клиент S3
var client *s3.Client
if endpoint != "" {
// Кастомный endpoint (например, для MinIO)
client = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
} else {
// Стандартный AWS S3
client = s3.NewFromConfig(cfg)
}
uploader := manager.NewUploader(client)
return &S3Service{
client: client,
uploader: uploader,
bucketName: bucketName,
}, nil
}
func (s *S3Service) UploadFile(filePath, fileName string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", filePath, err)
}
defer file.Close()
_, err = s.uploader.Upload(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(fileName),
Body: file,
})
if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err)
}
return nil
}
func (s *S3Service) FileUrl(fileName string) string {
endpoint := strings.TrimRight(os.Getenv("S3_ENDPOINT"), "/")
bucketName := os.Getenv("S3_BUCKET_NAME")
return fmt.Sprintf("%s/%s/%s", endpoint, bucketName, fileName)
}

View File

@@ -1,53 +1,116 @@
package service
import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service/s3"
"git.vakhrushev.me/av/transcriber/internal/service/speechkit"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"github.com/google/uuid"
)
const baseStorageDir = "data/files"
const (
defaultAudioExt = "audio"
)
type TranscribeService struct {
jobRepo contract.TranscriptJobRepository
fileRepo contract.FileRepository
converter contract.AudioFileConverter
jobRepo contract.TranscriptJobRepository
fileRepo contract.FileRepository
metaviewer contract.AudioMetaViewer
converter contract.AudioFileConverter
recognizer contract.AudioRecognizer
tgSender contract.TelegramMessageSender
storagePath string
logger *slog.Logger
}
func NewTranscribeService(jobRepo contract.TranscriptJobRepository, fileRepo contract.FileRepository, converter contract.AudioFileConverter) *TranscribeService {
func NewTranscribeService(
jobRepo contract.TranscriptJobRepository,
fileRepo contract.FileRepository,
metaviewer contract.AudioMetaViewer,
converter contract.AudioFileConverter,
recognizer contract.AudioRecognizer,
tgSender contract.TelegramMessageSender,
storagePath string,
logger *slog.Logger,
) *TranscribeService {
return &TranscribeService{
jobRepo: jobRepo,
fileRepo: fileRepo,
converter: converter,
jobRepo: jobRepo,
fileRepo: fileRepo,
metaviewer: metaviewer,
converter: converter,
recognizer: recognizer,
tgSender: tgSender,
storagePath: storagePath,
logger: logger,
}
}
func (s *TranscribeService) CreateTranscribeJob(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
jobId := uuid.NewString()
now := time.Now()
job := &entity.TranscribeJob{
Id: jobId,
State: entity.StateCreated,
Source: entity.SourceTelegram,
TgChatId: &chatId,
TgReplyMessageId: &replyMsgId,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
}
return s.createTranscribeJob(job, file, fileName)
}
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
jobId := uuid.NewString()
now := time.Now()
job := &entity.TranscribeJob{
Id: jobId,
State: entity.StateCreated,
Source: entity.SourceApi,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
}
return s.createTranscribeJob(job, file, fileName)
}
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
// Генерируем UUID для файла
fileId := uuid.NewString()
// Определяем расширение файла
ext := filepath.Ext(fileName)
if ext == "" {
ext = ".audio" // fallback если расширение не определено
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
}
// Создаем путь для сохранения файла
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
storageFilePath := filepath.Join(baseStorageDir, storageFileName)
storageFilePath := filepath.Join(s.storagePath, storageFileName)
s.logger.Info("Creating transcribe job",
"file_id", fileId,
"file_name", fileName,
"storage_path", storageFilePath)
// Создаем файл на диске
dst, err := os.Create(storageFilePath)
if err != nil {
s.logger.Error("Failed to create file", "error", err, "path", storageFilePath)
return nil, err
}
defer dst.Close()
@@ -55,9 +118,29 @@ func (s *TranscribeService) CreateTranscribeJob(file io.Reader, fileName string)
// Копируем содержимое загруженного файла
size, err := io.Copy(dst, file)
if err != nil {
s.logger.Error("Failed to copy file content", "error", err)
return nil, err
}
if err := dst.Close(); err != nil {
s.logger.Error("Failed to close file", "error", err)
return nil, err
}
info, err := s.metaviewer.GetInfo(storageFilePath)
if err != nil {
s.logger.Error("Failed to get file info", "error", err, "path", storageFilePath)
return nil, err
}
s.logger.Info("File uploaded successfully",
"file_id", fileId,
"size", size,
"duration_seconds", info.Seconds)
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
metrics.InputFileSizeHistogram.WithLabelValues(ext).Observe(float64(size))
// Создаем запись в таблице files
fileRecord := &entity.File{
Id: fileId,
@@ -70,62 +153,86 @@ func (s *TranscribeService) CreateTranscribeJob(file io.Reader, fileName string)
if err := s.fileRepo.Create(fileRecord); err != nil {
// Удаляем файл если не удалось создать запись в БД
os.Remove(storageFilePath)
s.logger.Error("Failed to create file record", "error", err, "file_id", fileId)
return nil, err
}
jobId := uuid.NewString()
now := time.Now()
// Создаем запись в таблице transcribe_jobs
job := &entity.TranscribeJob{
Id: jobId,
State: entity.StateCreated,
FileID: &fileId,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
}
job.FileID = &fileId
if err := s.jobRepo.Create(job); err != nil {
s.logger.Error("Failed to create job record", "error", err, "job_id", job.Id)
return nil, err
}
s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileId)
return job, nil
}
func (s *TranscribeService) FindAndRunConversionJob() error {
acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * time.Hour)
job, err := s.jobRepo.FindAndAcquire(entity.StateCreated, acquisitionId, rottingTime)
job, err := s.findJob(entity.StateCreated, time.Hour)
if err != nil {
if _, ok := err.(*contract.JobNotFoundError); ok {
return nil
}
return err
}
s.logger.Info("Starting conversion job", "job_id", job.Id)
srcFile, err := s.fileRepo.GetByID(*job.FileID)
if err != nil {
s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID)
return err
}
srcFilePath := filepath.Join(baseStorageDir, srcFile.FileName)
srcFilePath := filepath.Join(s.storagePath, srcFile.FileName)
destFileId := uuid.NewString()
destFileName := fmt.Sprintf("%s%s", destFileId, ".ogg")
destFilePath := filepath.Join(baseStorageDir, destFileName)
destFilePath := filepath.Join(s.storagePath, destFileName)
// Получаем расширение исходного файла для метрики
srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".")
if srcExt == "" {
srcExt = defaultAudioExt
}
s.logger.Info("Converting file",
"job_id", job.Id,
"src_path", srcFilePath,
"dest_path", destFilePath,
"src_format", srcExt)
// Измеряем время конвертации
startTime := time.Now()
err = s.converter.Convert(srcFilePath, destFilePath)
conversionDuration := time.Since(startTime)
// Записываем метрику времени конвертации
metrics.ConversionDurationHistogram.
WithLabelValues(srcExt, "ogg", strconv.FormatBool(err != nil)).
Observe(conversionDuration.Seconds())
if err != nil {
return err
s.logger.Error("File conversion failed",
"error", err,
"job_id", job.Id,
"duration", conversionDuration)
return s.failJob(job, err, "сбой конвертации файла")
}
stat, err := os.Stat(destFilePath)
if err != nil {
s.logger.Error("Failed to stat converted file", "error", err, "path", destFilePath)
return err
}
s.logger.Info("File conversion completed",
"job_id", job.Id,
"duration", conversionDuration,
"output_size", stat.Size())
// Записываем метрику размера выходного файла
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(stat.Size()))
// Создаем запись в таблице files
destFileRecord := &entity.File{
Id: destFileId,
@@ -140,154 +247,209 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
err = s.fileRepo.Create(destFileRecord)
if err != nil {
s.logger.Error("Failed to create converted file record", "error", err, "file_id", destFileId)
return err
}
err = s.jobRepo.Save(job)
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err
}
s.logger.Info("Conversion job completed successfully", "job_id", job.Id)
return nil
}
func (s *TranscribeService) FindAndRunTranscribeJob() error {
acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * time.Hour)
jobRecord, err := s.jobRepo.FindAndAcquire(entity.StateConverted, acquisitionId, rottingTime)
if err != nil {
if _, ok := err.(*contract.JobNotFoundError); ok {
return nil
}
return err
}
fileRecord, err := s.fileRepo.GetByID(*jobRecord.FileID)
job, err := s.findJob(entity.StateConverted, time.Hour)
if err != nil {
return err
}
filePath := filepath.Join(baseStorageDir, fileRecord.FileName)
s.logger.Info("Starting transcribe job", "job_id", job.Id)
fileRecord, err := s.fileRepo.GetByID(*job.FileID)
if err != nil {
s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID)
return err
}
filePath := filepath.Join(s.storagePath, fileRecord.FileName)
file, err := os.Open(filePath)
if err != nil {
s.logger.Error("Failed to open file", "error", err, "path", filePath)
return err
}
defer file.Close()
destFileId := uuid.NewString()
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
// Создаем S3 сервис
s3Service, err := s3.NewS3Service()
if err != nil {
return err
}
// Загружаем файл на S3
err = s3Service.UploadFile(filePath, destFileRecord.FileName)
if err != nil {
return err
}
// Создаем SpeechKit сервис
speechKitService, err := speechkit.NewSpeechKitService()
if err != nil {
return err
}
// Формируем S3 URI для файла
s3URI := s3Service.FileUrl(destFileRecord.FileName)
s.logger.Info("Starting recognition", "job_id", job.Id, "file_path", filePath)
// Запускаем асинхронное распознавание
operationID, err := speechKitService.RecognizeFileFromS3(s3URI)
operationID, err := s.recognizer.Recognize(file, destFileRecord.FileName)
if err != nil {
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
return err
}
s.logger.Info("Recognition started",
"job_id", job.Id,
"operation_id", operationID)
// Обновляем задачу с ID операции распознавания
jobRecord.FileID = &destFileId
jobRecord.RecognitionOpID = &operationID
job.FileID = &destFileId
job.RecognitionOpID = &operationID
delayTime := time.Now().Add(10 * time.Second)
jobRecord.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
err = s.fileRepo.Create(destFileRecord)
if err != nil {
s.logger.Error("Failed to create S3 file record", "error", err, "file_id", destFileId)
return err
}
err = s.jobRepo.Save(jobRecord)
err = s.jobRepo.Save(job)
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err
}
s.logger.Info("Transcribe job updated successfully", "job_id", job.Id)
return nil
}
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-24 * time.Hour)
job, err := s.jobRepo.FindAndAcquire(entity.StateTranscribe, acquisitionId, rottingTime)
job, err := s.findJob(entity.StateTranscribe, 24*time.Hour)
if err != nil {
if _, ok := err.(*contract.JobNotFoundError); ok {
return nil
}
return err
}
if job.RecognitionOpID == nil {
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
}
// Создаем SpeechKit сервис
speechKitService, err := speechkit.NewSpeechKitService()
if err != nil {
return err
}
defer speechKitService.Close()
opId := *job.RecognitionOpID
// Проверяем статус операции
log.Printf("Check operation status: id %s\n", opId)
operation, err := speechKitService.CheckOperationStatus(opId)
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId)
recResult, err := s.recognizer.CheckRecognitionStatus(opId)
if err != nil {
s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
return err
}
if !operation.Done {
if recResult.IsInProgress() {
// Операция еще не завершена, оставляем в статусе обработки
log.Printf("Operation in progress: id %s\n", opId)
delayTime := time.Now().Add(10 * time.Second)
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
delayTime := time.Now().Add(5 * time.Second)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
err := s.jobRepo.Save(job)
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err
}
return nil
}
if opErr := operation.GetError(); opErr != nil {
errorText := fmt.Sprintf("operation failed: code %d, message: %s", opErr.Code, opErr.Message)
log.Printf("Operation failed: id %s, message %s\n", opId, errorText)
job.Fail(errorText)
err := s.jobRepo.Save(job)
if err != nil {
return err
}
return nil
if recResult.IsFailed() {
errorText := recResult.GetError()
s.logger.Error("Operation failed",
"job_id", job.Id,
"operation_id", opId,
"error_message", errorText)
return s.failJob(job, errors.New(errorText), "сбой при распознавании файла")
}
// Операция завершена, получаем результат
transcriptionText, err := speechKitService.GetRecognitionText(*job.RecognitionOpID)
transcriptionText, err := s.recognizer.GetRecognitionText(opId)
if err != nil {
s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
return err
}
log.Printf("Operation done: id %s\n", opId)
s.logger.Info("Transcribe operation completed successfully",
"job_id", job.Id,
"operation_id", opId,
"text_length", len(transcriptionText))
// Завершаем задачу
return s.completeJob(job, transcriptionText)
}
func (s *TranscribeService) findJob(state string, expiration time.Duration) (job *entity.TranscribeJob, err error) {
acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * expiration)
job, err = s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
if err != nil {
if _, ok := err.(*contract.JobNotFoundError); ok {
return nil, &contract.NoopJobError{State: state}
}
s.logger.Error("Failed to find and acquire job", "state", state, "error", err)
return nil, fmt.Errorf("failed find and acquire job: %s, %w", state, err)
}
return job, nil
}
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, transcriptionText string) error {
// Обновляем задачу с результатом
job.Done(transcriptionText)
err = s.jobRepo.Save(job)
// Сохраняем задачу в базу
err := s.jobRepo.Save(job)
if err != nil {
return err
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return fmt.Errorf("failed to save job: %w", err)
}
// Отправляем распознанный текст обратно пользователю
switch job.Source {
case entity.SourceTelegram:
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
}
err := s.tgSender.Send(transcriptionText, *job.TgChatId, job.TgReplyMessageId)
if err != nil {
s.logger.Error("Failed to sent transcription text to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
}
}
return nil
}
func (s *TranscribeService) failJob(job *entity.TranscribeJob, jobErr error, humanErrorText string) error {
// Обновляем задачу с результатом
job.Fail(jobErr.Error())
// Сохраняем задачу в базу
err := s.jobRepo.Save(job)
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return fmt.Errorf("failed to save job: %w", err)
}
// Отправляем распознанный текст обратно пользователю
switch job.Source {
case entity.SourceTelegram:
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
}
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
err := s.tgSender.Send(errorMessage, *job.TgChatId, job.TgReplyMessageId)
if err != nil {
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
}
}
return nil

11
lefthook.yml Normal file
View File

@@ -0,0 +1,11 @@
# Refer for explanation to following link:
# https://lefthook.dev/configuration/
templates:
av-hooks-dir: "/home/av/projects/private/git-hooks"
pre-commit:
jobs:
- name: "gitleaks"
run: "gitleaks git --staged"

201
main.go
View File

@@ -3,8 +3,10 @@ package main
import (
"context"
"database/sql"
"embed"
"flag"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
@@ -12,9 +14,14 @@ import (
"syscall"
"time"
"git.vakhrushev.me/av/transcriber/internal/adapter/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/sqlite"
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"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
@@ -23,54 +30,111 @@ import (
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
"github.com/prometheus/client_golang/prometheus/promhttp"
sloggin "github.com/samber/slog-gin"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
const (
ServerShutdownTimeout = 5
ForceShutdownTimeout = 20
)
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)
}
// Загружаем переменные окружения из .env файла
if err := godotenv.Load(); err != nil {
log.Println("Warning: .env file not found, using system environment variables")
logger.Warn("Warning: .env file not found, using system environment variables")
}
// Создаем директории если они не существуют
if err := os.MkdirAll("data/files", 0755); err != nil {
log.Fatal("Failed to create data/files directory:", err)
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil {
logger.Error("Failed to create file storage directory", "path", cfg.Storage.Path, "error", err)
os.Exit(1)
}
db, err := sql.Open("sqlite3", "data/transcriber.db")
db, err := sql.Open("sqlite3", cfg.Database.Path)
if err != nil {
log.Fatalf("failed to open database: %v", err)
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("failed to ping database: %v", err)
logger.Error("failed to ping database", "error", err)
os.Exit(1)
}
gq := goqu.New("sqlite3", db)
// Запускаем миграции
if err := RunMigrations(db, "migrations"); err != nil {
log.Fatal("Failed to run migrations:", err)
if err := RunMigrations(db, logger); err != nil {
logger.Error("Failed to run migrations", "error", err)
os.Exit(1)
}
// Создаем репозитории
fileRepo := sqlite.NewFileRepository(db, gq)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
converter := ffmpeg.NewFfmpegConverter()
// Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter()
transcribeService := service.NewTranscribeService(jobRepo, fileRepo, converter)
// Создаем воркеры
conversionWorker := worker.NewConversionWorker(transcribeService)
transcribeWorker := worker.NewTranscribeWorker(transcribeService)
checkWorker := worker.NewCheckWorker(transcribeService)
workers := []worker.Worker{
conversionWorker,
transcribeWorker,
checkWorker,
tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger)
if err != nil {
logger.Error("failed to create audio telegram sender", "error", err)
os.Exit(1)
}
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)
}
defer recognizer.Close()
// Создаем сервисы
transcribeService := service.NewTranscribeService(
jobRepo,
fileRepo,
metaviewer,
converter,
recognizer,
tgSender,
cfg.Storage.Path,
logger,
)
// Создаем контекст для graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -78,19 +142,57 @@ func main() {
// Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup
tgConfig := tgcontroller.TelegramConfig{
BotToken: cfg.Telegram.BotToken,
UpdateTimeout: cfg.Telegram.UpdateTimeout,
UserWhiteList: cfg.Server.UsersWhiteList,
}
// Создаем Telegram бот
tgController, err := tgcontroller.NewTelegramController(tgConfig, transcribeService, jobRepo, logger)
if err != nil {
logger.Error("Failed to create Telegram controller", "error", err)
// Не останавливаем приложение, если Telegram бот не создан
} else {
// Запускаем Telegram бот в отдельной горутине
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("Starting Telegram bot")
tgController.Start()
logger.Info("Telegram bot stopped gracefully")
}()
}
// Создаем воркеры
conversionWorker := worker.NewCallbackWorker("conversion_worker", transcribeService.FindAndRunConversionJob, logger)
transcribeWorker := worker.NewCallbackWorker("transcribe_worker", transcribeService.FindAndRunTranscribeJob, logger)
checkWorker := worker.NewCallbackWorker("check_worker", transcribeService.FindAndRunTranscribeCheckJob, logger)
workers := []worker.Worker{
conversionWorker,
transcribeWorker,
checkWorker,
}
// Запускаем воркеры в отдельных горутинах
for _, w := range workers {
wg.Add(1)
go func(worker worker.Worker) {
defer wg.Done()
worker.Start(ctx)
log.Printf("%s stopped", worker.Name())
logger.Info("Worker stopped gracefully", "worker", worker.Name())
}(w)
}
// Создаем Gin middleware для логирования
gin.SetMode(gin.DebugMode)
router := gin.New()
router.Use(sloggin.New(logger))
router.Use(gin.Recovery())
// Запускаем HTTP сервер для API (создание задач и проверка статуса)
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService)
router := gin.Default()
// Настраиваем роуты только для создания задач и проверки статуса
api := router.Group("/api")
@@ -110,9 +212,12 @@ func main() {
})
})
// Добавляем эндпоинт для метрик Prometheus
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
// Создаем HTTP сервер
srv := &http.Server{
Addr: ":8080",
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
}
@@ -120,9 +225,9 @@ func main() {
wg.Add(1)
go func() {
defer wg.Done()
log.Println("Starting HTTP server on :8080")
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP server error: %v", err)
logger.Error("HTTP server error", "error", err)
}
}()
@@ -130,24 +235,29 @@ func main() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
log.Println("Transcriber service started with background workers")
log.Println("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
log.Println("Press Ctrl+C to stop...")
logger.Info("Transcriber service started with background workers")
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения
<-sigChan
log.Println("Received shutdown signal, initiating graceful shutdown...")
logger.Info("Received shutdown signal, initiating graceful shutdown...")
if tgController != nil {
logger.Info("Shutting down Telegram bot...")
tgController.Stop()
}
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel()
// Останавливаем HTTP сервер
log.Println("Shutting down HTTP server...")
logger.Info("Shutting down HTTP server...")
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP server forced to shutdown: %v", err)
logger.Error("HTTP server forced to shutdown", "error", err)
} else {
log.Println("HTTP server stopped gracefully")
logger.Info("HTTP server stopped gracefully")
}
// Отменяем контекст для остановки воркеров
@@ -160,26 +270,29 @@ func main() {
close(done)
}()
// Ждем завершения всех воркеров или таймаута в 10 секунд
// Ждем завершения всех воркеров или таймаута
select {
case <-done:
log.Println("All workers stopped gracefully")
case <-time.After(10 * time.Second):
log.Println("Timeout reached, forcing shutdown")
logger.Info("All workers stopped gracefully")
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
logger.Warn("Timeout reached, forcing shutdown")
}
log.Println("Transcriber service stopped")
logger.Info("Transcriber service stopped")
}
func RunMigrations(db *sql.DB, migrationsDir string) error {
func RunMigrations(db *sql.DB, logger *slog.Logger) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
if err := goose.Up(db, migrationsDir); err != nil {
// Use the embedded filesystem for migrations
goose.SetBaseFS(migrationsFS)
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
log.Println("Migrations completed successfully")
logger.Info("Migrations completed successfully")
return nil
}

View File

@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE transcribe_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE transcribe_jobs ADD COLUMN tg_chat_id INTEGER;
ALTER TABLE transcribe_jobs ADD COLUMN tg_reply_message_id INTEGER;
-- +goose Down
ALTER TABLE transcribe_jobs DROP COLUMN source;
ALTER TABLE transcribe_jobs DROP COLUMN tg_chat_id;
ALTER TABLE transcribe_jobs DROP COLUMN tg_reply_message_id;