Compare commits
23 Commits
03106830e5
...
master
Author | SHA1 | Date | |
---|---|---|---|
822e1680fb
|
|||
12b16b3749
|
|||
8eddab4455
|
|||
3b71b190a0
|
|||
946a1ea151
|
|||
7b4a1462e6
|
|||
a1c394ba89
|
|||
df069a9aa1
|
|||
aae83db2ea
|
|||
137da5a893
|
|||
121585f807
|
|||
85db17b131
|
|||
a284e3ef29
|
|||
8fad4c5033
|
|||
ad886ea985
|
|||
9dd5f47010
|
|||
d957800e18
|
|||
900c7ecb51
|
|||
eb0dea6113
|
|||
22cbaf0bca
|
|||
bab563519c
|
|||
f6b5e835a4
|
|||
3f31bd5ff2
|
21
.env.example
21
.env.example
@@ -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
6
.gitignore
vendored
@@ -48,10 +48,8 @@ Thumbs.db
|
|||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Environment files
|
# Config files
|
||||||
.env
|
config.toml
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Sample and test audio files
|
# Sample and test audio files
|
||||||
*.m4a
|
*.m4a
|
||||||
|
72
Dockerfile
Normal file
72
Dockerfile
Normal 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
28
Taskfile.yml
Normal 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
45
config.dist.toml
Normal 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
41
docker/entrypoint.sh
Executable 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
30
go.mod
@@ -3,6 +3,7 @@ module git.vakhrushev.me/av/transcriber
|
|||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
require (
|
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 v1.37.2
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.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/aws/aws-sdk-go-v2/service/s3 v1.86.0
|
||||||
github.com/doug-martin/goqu/v9 v9.19.0
|
github.com/doug-martin/goqu/v9 v9.19.0
|
||||||
github.com/gin-gonic/gin v1.10.1
|
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/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.17
|
github.com/mattn/go-sqlite3 v1.14.31
|
||||||
github.com/pressly/goose/v3 v3.15.1
|
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/stretchr/testify v1.10.0
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0
|
github.com/yandex-cloud/go-genproto v0.17.0
|
||||||
google.golang.org/grpc v1.74.2
|
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/ssooidc v1.32.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.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/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/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/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // 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/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // 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/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.38.0 // indirect
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
golang.org/x/net v0.40.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/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||||
|
110
go.sum
110
go.sum
@@ -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 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
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=
|
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/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 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
|
||||||
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
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 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
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=
|
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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
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/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
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/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-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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
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.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.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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.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.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||||
github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
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.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 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
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/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
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.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 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
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.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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
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 h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
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=
|
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/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 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||||
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||||
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=
|
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
72
internal/adapter/metaviewer/ffmpeg/ffmpeg.go
Normal file
72
internal/adapter/metaviewer/ffmpeg/ffmpeg.go
Normal 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
|
||||||
|
}
|
22
internal/adapter/recognizer/memory.go
Normal file
22
internal/adapter/recognizer/memory.go
Normal 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
|
||||||
|
}
|
94
internal/adapter/recognizer/yandex/recognizer.go
Normal file
94
internal/adapter/recognizer/yandex/recognizer.go
Normal 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
|
||||||
|
}
|
84
internal/adapter/recognizer/yandex/s3.go
Normal file
84
internal/adapter/recognizer/yandex/s3.go
Normal 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)
|
||||||
|
}
|
@@ -1,9 +1,8 @@
|
|||||||
package speechkit
|
package yandex
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -21,7 +20,12 @@ const (
|
|||||||
RecognitionModel = "deferred-general"
|
RecognitionModel = "deferred-general"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SpeechKitService struct {
|
type speechKitConfig struct {
|
||||||
|
ApiKey string
|
||||||
|
FolderID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type speechKitService struct {
|
||||||
sttConn *grpc.ClientConn
|
sttConn *grpc.ClientConn
|
||||||
opConn *grpc.ClientConn
|
opConn *grpc.ClientConn
|
||||||
sttClient stt.AsyncRecognizerClient
|
sttClient stt.AsyncRecognizerClient
|
||||||
@@ -30,9 +34,9 @@ type SpeechKitService struct {
|
|||||||
folderID string
|
folderID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpeechKitService() (*SpeechKitService, error) {
|
func newSpeechKitService(cfg speechKitConfig) (*speechKitService, error) {
|
||||||
apiKey := os.Getenv("YANDEX_CLOUD_API_KEY")
|
apiKey := cfg.ApiKey
|
||||||
folderID := os.Getenv("YANDEX_CLOUD_FOLDER_ID")
|
folderID := cfg.FolderID
|
||||||
|
|
||||||
if apiKey == "" || folderID == "" {
|
if apiKey == "" || folderID == "" {
|
||||||
return nil, fmt.Errorf("missing required Yandex Cloud environment variables")
|
return nil, fmt.Errorf("missing required Yandex Cloud environment variables")
|
||||||
@@ -55,7 +59,7 @@ func NewSpeechKitService() (*SpeechKitService, error) {
|
|||||||
sttClient := stt.NewAsyncRecognizerClient(sttConn)
|
sttClient := stt.NewAsyncRecognizerClient(sttConn)
|
||||||
opClient := operation.NewOperationServiceClient(opConn)
|
opClient := operation.NewOperationServiceClient(opConn)
|
||||||
|
|
||||||
return &SpeechKitService{
|
return &speechKitService{
|
||||||
sttConn: sttConn,
|
sttConn: sttConn,
|
||||||
opConn: opConn,
|
opConn: opConn,
|
||||||
sttClient: sttClient,
|
sttClient: sttClient,
|
||||||
@@ -65,7 +69,7 @@ func NewSpeechKitService() (*SpeechKitService, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SpeechKitService) Close() error {
|
func (s *speechKitService) Close() error {
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
if s.sttConn != nil {
|
if s.sttConn != nil {
|
||||||
err1 = s.sttConn.Close()
|
err1 = s.sttConn.Close()
|
||||||
@@ -79,8 +83,8 @@ func (s *SpeechKitService) Close() error {
|
|||||||
return err2
|
return err2
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
||||||
func (s *SpeechKitService) RecognizeFileFromS3(s3URI string) (string, error) {
|
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
// Добавляем авторизацию и folder_id в контекст
|
||||||
@@ -123,7 +127,7 @@ func (s *SpeechKitService) RecognizeFileFromS3(s3URI string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetRecognitionResult получает результат распознавания по ID операции
|
// GetRecognitionResult получает результат распознавания по ID операции
|
||||||
func (s *SpeechKitService) GetRecognitionText(operationID string) (string, error) {
|
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
// Добавляем авторизацию и folder_id в контекст
|
||||||
@@ -162,8 +166,8 @@ func (s *SpeechKitService) GetRecognitionText(operationID string) (string, error
|
|||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckOperationStatus проверяет статус операции распознавания
|
// checkOperationStatus проверяет статус операции распознавания
|
||||||
func (s *SpeechKitService) CheckOperationStatus(operationID string) (*operation.Operation, error) {
|
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
@@ -21,18 +21,21 @@ func NewTranscriptJobRepository(db *sql.DB, gq *goqu.Database) *TranscriptJobRep
|
|||||||
|
|
||||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||||
record := goqu.Record{
|
record := goqu.Record{
|
||||||
"id": job.Id,
|
"id": job.Id,
|
||||||
"state": job.State,
|
"state": job.State,
|
||||||
"file_id": job.FileID,
|
"source": job.Source,
|
||||||
"is_error": job.IsError,
|
"file_id": job.FileID,
|
||||||
"error_text": job.ErrorText,
|
"is_error": job.IsError,
|
||||||
"acquisition_id": job.AcquisitionID,
|
"error_text": job.ErrorText,
|
||||||
"acquire_time": job.AcquireTime,
|
"acquisition_id": job.AcquisitionID,
|
||||||
"delay_time": job.DelayTime,
|
"acquire_time": job.AcquireTime,
|
||||||
"recognition_op_id": job.RecognitionOpID,
|
"delay_time": job.DelayTime,
|
||||||
"transcription_text": job.TranscriptionText,
|
"recognition_op_id": job.RecognitionOpID,
|
||||||
"created_at": job.CreatedAt,
|
"transcription_text": job.TranscriptionText,
|
||||||
"updated_at": job.UpdatedAt,
|
"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)
|
query := repo.gq.Insert("transcribe_jobs").Rows(record)
|
||||||
sql, args, err := query.ToSQL()
|
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 {
|
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob) error {
|
||||||
record := goqu.Record{
|
record := goqu.Record{
|
||||||
"state": job.State,
|
"state": job.State,
|
||||||
"file_id": job.FileID,
|
"source": job.Source,
|
||||||
"is_error": job.IsError,
|
"file_id": job.FileID,
|
||||||
"error_text": job.ErrorText,
|
"is_error": job.IsError,
|
||||||
"acquisition_id": job.AcquisitionID,
|
"error_text": job.ErrorText,
|
||||||
"acquire_time": job.AcquireTime,
|
"acquisition_id": job.AcquisitionID,
|
||||||
"delay_time": job.DelayTime,
|
"acquire_time": job.AcquireTime,
|
||||||
"recognition_op_id": job.RecognitionOpID,
|
"delay_time": job.DelayTime,
|
||||||
"transcription_text": job.TranscriptionText,
|
"recognition_op_id": job.RecognitionOpID,
|
||||||
"updated_at": job.UpdatedAt,
|
"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))
|
query := repo.gq.Update("transcribe_jobs").Set(record).Where(goqu.C("id").Eq(job.Id))
|
||||||
sql, args, err := query.ToSQL()
|
sql, args, err := query.ToSQL()
|
||||||
@@ -79,6 +85,7 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
|
|||||||
query := repo.gq.From("transcribe_jobs").Select(
|
query := repo.gq.From("transcribe_jobs").Select(
|
||||||
"id",
|
"id",
|
||||||
"state",
|
"state",
|
||||||
|
"source",
|
||||||
"file_id",
|
"file_id",
|
||||||
"is_error",
|
"is_error",
|
||||||
"error_text",
|
"error_text",
|
||||||
@@ -87,6 +94,8 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
|
|||||||
"delay_time",
|
"delay_time",
|
||||||
"recognition_op_id",
|
"recognition_op_id",
|
||||||
"transcription_text",
|
"transcription_text",
|
||||||
|
"tg_chat_id",
|
||||||
|
"tg_reply_message_id",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
).Where(goqu.C("id").Eq(id))
|
).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(
|
err = repo.db.QueryRow(sql, args...).Scan(
|
||||||
&job.Id,
|
&job.Id,
|
||||||
&job.State,
|
&job.State,
|
||||||
|
&job.Source,
|
||||||
&job.FileID,
|
&job.FileID,
|
||||||
&job.IsError,
|
&job.IsError,
|
||||||
&job.ErrorText,
|
&job.ErrorText,
|
||||||
@@ -107,6 +117,8 @@ func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob,
|
|||||||
&job.DelayTime,
|
&job.DelayTime,
|
||||||
&job.RecognitionOpID,
|
&job.RecognitionOpID,
|
||||||
&job.TranscriptionText,
|
&job.TranscriptionText,
|
||||||
|
&job.TgChatId,
|
||||||
|
&job.TgReplyMessageId,
|
||||||
&job.CreatedAt,
|
&job.CreatedAt,
|
||||||
&job.UpdatedAt,
|
&job.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -172,6 +184,7 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
|
|||||||
selectQuery := repo.gq.From("transcribe_jobs").Select(
|
selectQuery := repo.gq.From("transcribe_jobs").Select(
|
||||||
"id",
|
"id",
|
||||||
"state",
|
"state",
|
||||||
|
"source",
|
||||||
"file_id",
|
"file_id",
|
||||||
"is_error",
|
"is_error",
|
||||||
"error_text",
|
"error_text",
|
||||||
@@ -180,6 +193,8 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
|
|||||||
"delay_time",
|
"delay_time",
|
||||||
"recognition_op_id",
|
"recognition_op_id",
|
||||||
"transcription_text",
|
"transcription_text",
|
||||||
|
"tg_chat_id",
|
||||||
|
"tg_reply_message_id",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
).Where(goqu.C("acquisition_id").Eq(acquisitionId))
|
).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(
|
err = repo.db.QueryRow(sql, args...).Scan(
|
||||||
&job.Id,
|
&job.Id,
|
||||||
&job.State,
|
&job.State,
|
||||||
|
&job.Source,
|
||||||
&job.FileID,
|
&job.FileID,
|
||||||
&job.IsError,
|
&job.IsError,
|
||||||
&job.ErrorText,
|
&job.ErrorText,
|
||||||
@@ -201,6 +217,8 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
|
|||||||
&job.DelayTime,
|
&job.DelayTime,
|
||||||
&job.RecognitionOpID,
|
&job.RecognitionOpID,
|
||||||
&job.TranscriptionText,
|
&job.TranscriptionText,
|
||||||
|
&job.TgChatId,
|
||||||
|
&job.TgReplyMessageId,
|
||||||
&job.CreatedAt,
|
&job.CreatedAt,
|
||||||
&job.UpdatedAt,
|
&job.UpdatedAt,
|
||||||
)
|
)
|
67
internal/adapter/telegram/sender.go
Normal file
67
internal/adapter/telegram/sender.go
Normal 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
|
||||||
|
}
|
62
internal/adapter/telegram/split.go
Normal file
62
internal/adapter/telegram/split.go
Normal 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
|
||||||
|
}
|
125
internal/adapter/telegram/split_test.go
Normal file
125
internal/adapter/telegram/split_test.go
Normal 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
93
internal/config/config.go
Normal 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
|
||||||
|
}
|
@@ -1,8 +1,29 @@
|
|||||||
package contract
|
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 {
|
type AudioFileConverter interface {
|
||||||
Convert(src, dest string) error
|
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
|
||||||
|
}
|
||||||
|
@@ -10,3 +10,11 @@ type JobNotFoundError struct {
|
|||||||
func (e *JobNotFoundError) Error() string {
|
func (e *JobNotFoundError) Error() string {
|
||||||
return fmt.Sprintf("%s - %s", e.State, e.Message)
|
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)
|
||||||
|
}
|
||||||
|
@@ -10,8 +10,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseStorageDir = "data/files"
|
|
||||||
|
|
||||||
type TranscribeHandler struct {
|
type TranscribeHandler struct {
|
||||||
jobRepo contract.TranscriptJobRepository
|
jobRepo contract.TranscriptJobRepository
|
||||||
trsService *service.TranscribeService
|
trsService *service.TranscribeService
|
||||||
@@ -42,7 +40,7 @@ func (h *TranscribeHandler) CreateTranscribeJob(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
job, err := h.trsService.CreateTranscribeJob(file, header.Filename)
|
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Err: %v", err)
|
log.Printf("Err: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcibe job"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcibe job"})
|
||||||
|
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -15,8 +16,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/ffmpeg"
|
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/sqlite"
|
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/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
"github.com/doug-martin/goqu/v9"
|
"github.com/doug-martin/goqu/v9"
|
||||||
@@ -57,9 +60,25 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) {
|
|||||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||||
jobRepo := sqlite.NewTranscriptJobRepository(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)
|
handler := NewTranscribeHandler(jobRepo, trsService)
|
||||||
|
|
||||||
@@ -364,3 +383,9 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "Job not found", response["error"])
|
assert.Equal(t, "Job not found", response["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TestTgSender struct{}
|
||||||
|
|
||||||
|
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
306
internal/controller/tg/tg.go
Normal file
306
internal/controller/tg/tg.go
Normal 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"
|
||||||
|
}
|
@@ -2,10 +2,12 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Worker представляет базовый интерфейс для всех воркеров
|
// Worker представляет базовый интерфейс для всех воркеров
|
||||||
@@ -14,121 +16,50 @@ type Worker interface {
|
|||||||
Name() string
|
Name() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConversionWorker обрабатывает задачи конвертации
|
type CallbackWorker struct {
|
||||||
type ConversionWorker struct {
|
name string
|
||||||
transcribeService *service.TranscribeService
|
f func() error
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConversionWorker(transcribeService *service.TranscribeService) *ConversionWorker {
|
func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *CallbackWorker {
|
||||||
return &ConversionWorker{
|
if logger == nil {
|
||||||
transcribeService: transcribeService,
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CallbackWorker{
|
||||||
|
name: name,
|
||||||
|
f: f,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *ConversionWorker) Name() string {
|
func (w *CallbackWorker) Name() string {
|
||||||
return "ConversionWorker"
|
return w.name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *ConversionWorker) Start(ctx context.Context) {
|
func (w *CallbackWorker) Start(ctx context.Context) {
|
||||||
log.Printf("%s started", w.Name())
|
w.logger.Info("Worker started", "worker", w.Name())
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Printf("%s received shutdown signal", w.Name())
|
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
err := w.transcribeService.FindAndRunConversionJob()
|
err := w.f()
|
||||||
if err != nil {
|
_, isNoop := err.(*contract.NoopJobError)
|
||||||
log.Printf("%s error: %v", w.Name(), err)
|
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 секунду перед следующей итерацией
|
// Ждем 1 секунду перед следующей итерацией
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
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):
|
|
||||||
// Продолжаем работу
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
|
||||||
return
|
return
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(1 * time.Second):
|
||||||
// Продолжаем работу
|
// Продолжаем работу
|
||||||
|
@@ -7,6 +7,7 @@ import (
|
|||||||
type TranscribeJob struct {
|
type TranscribeJob struct {
|
||||||
Id string
|
Id string
|
||||||
State string
|
State string
|
||||||
|
Source string
|
||||||
FileID *string
|
FileID *string
|
||||||
IsError bool
|
IsError bool
|
||||||
ErrorText *string
|
ErrorText *string
|
||||||
@@ -15,6 +16,8 @@ type TranscribeJob struct {
|
|||||||
DelayTime *time.Time
|
DelayTime *time.Time
|
||||||
RecognitionOpID *string // ID операции распознавания в Yandex Cloud
|
RecognitionOpID *string // ID операции распознавания в Yandex Cloud
|
||||||
TranscriptionText *string // Результат распознавания
|
TranscriptionText *string // Результат распознавания
|
||||||
|
TgChatId *int64 // Telegram: в какой чат отправить результат распознавания
|
||||||
|
TgReplyMessageId *int // Telegram: с каким сообщением связать результат распознавания
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,14 @@ const (
|
|||||||
StateFailed = "failed"
|
StateFailed = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceUnknown = "unknown"
|
||||||
|
SourceApi = "api"
|
||||||
|
SourceTelegram = "telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Переводит задачу в новое состояние, при этом очищает все
|
||||||
|
// служебные поля предыдущего состояния, как-то время задержки, информацию о воркере и тд
|
||||||
func (j *TranscribeJob) MoveToState(state string) {
|
func (j *TranscribeJob) MoveToState(state string) {
|
||||||
j.State = state
|
j.State = state
|
||||||
j.DelayTime = nil
|
j.DelayTime = nil
|
||||||
|
78
internal/entity/recognition.go
Normal file
78
internal/entity/recognition.go
Normal 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 ""
|
||||||
|
}
|
56
internal/metrics/metrics.go
Normal file
56
internal/metrics/metrics.go
Normal 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"},
|
||||||
|
)
|
||||||
|
)
|
@@ -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)
|
|
||||||
}
|
|
@@ -1,53 +1,116 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service/s3"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service/speechkit"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseStorageDir = "data/files"
|
const (
|
||||||
|
defaultAudioExt = "audio"
|
||||||
|
)
|
||||||
|
|
||||||
type TranscribeService struct {
|
type TranscribeService struct {
|
||||||
jobRepo contract.TranscriptJobRepository
|
jobRepo contract.TranscriptJobRepository
|
||||||
fileRepo contract.FileRepository
|
fileRepo contract.FileRepository
|
||||||
converter contract.AudioFileConverter
|
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{
|
return &TranscribeService{
|
||||||
jobRepo: jobRepo,
|
jobRepo: jobRepo,
|
||||||
fileRepo: fileRepo,
|
fileRepo: fileRepo,
|
||||||
converter: converter,
|
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 для файла
|
// Генерируем UUID для файла
|
||||||
fileId := uuid.NewString()
|
fileId := uuid.NewString()
|
||||||
|
|
||||||
// Определяем расширение файла
|
// Определяем расширение файла
|
||||||
ext := filepath.Ext(fileName)
|
ext := filepath.Ext(fileName)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = ".audio" // fallback если расширение не определено
|
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем путь для сохранения файла
|
// Создаем путь для сохранения файла
|
||||||
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
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)
|
dst, err := os.Create(storageFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create file", "error", err, "path", storageFilePath)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
@@ -55,9 +118,29 @@ func (s *TranscribeService) CreateTranscribeJob(file io.Reader, fileName string)
|
|||||||
// Копируем содержимое загруженного файла
|
// Копируем содержимое загруженного файла
|
||||||
size, err := io.Copy(dst, file)
|
size, err := io.Copy(dst, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to copy file content", "error", err)
|
||||||
return nil, 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
|
// Создаем запись в таблице files
|
||||||
fileRecord := &entity.File{
|
fileRecord := &entity.File{
|
||||||
Id: fileId,
|
Id: fileId,
|
||||||
@@ -70,62 +153,86 @@ func (s *TranscribeService) CreateTranscribeJob(file io.Reader, fileName string)
|
|||||||
if err := s.fileRepo.Create(fileRecord); err != nil {
|
if err := s.fileRepo.Create(fileRecord); err != nil {
|
||||||
// Удаляем файл если не удалось создать запись в БД
|
// Удаляем файл если не удалось создать запись в БД
|
||||||
os.Remove(storageFilePath)
|
os.Remove(storageFilePath)
|
||||||
|
s.logger.Error("Failed to create file record", "error", err, "file_id", fileId)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
jobId := uuid.NewString()
|
job.FileID = &fileId
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Создаем запись в таблице transcribe_jobs
|
|
||||||
job := &entity.TranscribeJob{
|
|
||||||
Id: jobId,
|
|
||||||
State: entity.StateCreated,
|
|
||||||
FileID: &fileId,
|
|
||||||
IsError: false,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.jobRepo.Create(job); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileId)
|
||||||
|
|
||||||
return job, nil
|
return job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) FindAndRunConversionJob() error {
|
func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||||
acquisitionId := uuid.NewString()
|
job, err := s.findJob(entity.StateCreated, time.Hour)
|
||||||
rottingTime := time.Now().Add(-1 * time.Hour)
|
|
||||||
|
|
||||||
job, err := s.jobRepo.FindAndAcquire(entity.StateCreated, acquisitionId, rottingTime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(*contract.JobNotFoundError); ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Starting conversion job", "job_id", job.Id)
|
||||||
|
|
||||||
srcFile, err := s.fileRepo.GetByID(*job.FileID)
|
srcFile, err := s.fileRepo.GetByID(*job.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
srcFilePath := filepath.Join(baseStorageDir, srcFile.FileName)
|
srcFilePath := filepath.Join(s.storagePath, srcFile.FileName)
|
||||||
|
|
||||||
destFileId := uuid.NewString()
|
destFileId := uuid.NewString()
|
||||||
destFileName := fmt.Sprintf("%s%s", destFileId, ".ogg")
|
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)
|
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 {
|
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)
|
stat, err := os.Stat(destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to stat converted file", "error", err, "path", destFilePath)
|
||||||
return err
|
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
|
// Создаем запись в таблице files
|
||||||
destFileRecord := &entity.File{
|
destFileRecord := &entity.File{
|
||||||
Id: destFileId,
|
Id: destFileId,
|
||||||
@@ -140,154 +247,209 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
|
|||||||
|
|
||||||
err = s.fileRepo.Create(destFileRecord)
|
err = s.fileRepo.Create(destFileRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create converted file record", "error", err, "file_id", destFileId)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.jobRepo.Save(job)
|
err = s.jobRepo.Save(job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Conversion job completed successfully", "job_id", job.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||||
acquisitionId := uuid.NewString()
|
job, err := s.findJob(entity.StateConverted, time.Hour)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
destFileId := uuid.NewString()
|
||||||
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
|
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
|
||||||
|
|
||||||
// Создаем S3 сервис
|
s.logger.Info("Starting recognition", "job_id", job.Id, "file_path", filePath)
|
||||||
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)
|
|
||||||
|
|
||||||
// Запускаем асинхронное распознавание
|
// Запускаем асинхронное распознавание
|
||||||
operationID, err := speechKitService.RecognizeFileFromS3(s3URI)
|
operationID, err := s.recognizer.Recognize(file, destFileRecord.FileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Recognition started",
|
||||||
|
"job_id", job.Id,
|
||||||
|
"operation_id", operationID)
|
||||||
|
|
||||||
// Обновляем задачу с ID операции распознавания
|
// Обновляем задачу с ID операции распознавания
|
||||||
jobRecord.FileID = &destFileId
|
job.FileID = &destFileId
|
||||||
jobRecord.RecognitionOpID = &operationID
|
job.RecognitionOpID = &operationID
|
||||||
delayTime := time.Now().Add(10 * time.Second)
|
delayTime := time.Now().Add(10 * time.Second)
|
||||||
jobRecord.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||||
|
|
||||||
err = s.fileRepo.Create(destFileRecord)
|
err = s.fileRepo.Create(destFileRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create S3 file record", "error", err, "file_id", destFileId)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.jobRepo.Save(jobRecord)
|
err = s.jobRepo.Save(job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Transcribe job updated successfully", "job_id", job.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||||
acquisitionId := uuid.NewString()
|
job, err := s.findJob(entity.StateTranscribe, 24*time.Hour)
|
||||||
rottingTime := time.Now().Add(-24 * time.Hour)
|
|
||||||
|
|
||||||
job, err := s.jobRepo.FindAndAcquire(entity.StateTranscribe, acquisitionId, rottingTime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(*contract.JobNotFoundError); ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if job.RecognitionOpID == nil {
|
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)
|
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
|
opId := *job.RecognitionOpID
|
||||||
|
|
||||||
// Проверяем статус операции
|
// Проверяем статус операции
|
||||||
log.Printf("Check operation status: id %s\n", opId)
|
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId)
|
||||||
operation, err := speechKitService.CheckOperationStatus(opId)
|
recResult, err := s.recognizer.CheckRecognitionStatus(opId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !operation.Done {
|
if recResult.IsInProgress() {
|
||||||
// Операция еще не завершена, оставляем в статусе обработки
|
// Операция еще не завершена, оставляем в статусе обработки
|
||||||
log.Printf("Operation in progress: id %s\n", opId)
|
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
|
||||||
delayTime := time.Now().Add(10 * time.Second)
|
delayTime := time.Now().Add(5 * time.Second)
|
||||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||||
err := s.jobRepo.Save(job)
|
err := s.jobRepo.Save(job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if opErr := operation.GetError(); opErr != nil {
|
if recResult.IsFailed() {
|
||||||
errorText := fmt.Sprintf("operation failed: code %d, message: %s", opErr.Code, opErr.Message)
|
errorText := recResult.GetError()
|
||||||
log.Printf("Operation failed: id %s, message %s\n", opId, errorText)
|
s.logger.Error("Operation failed",
|
||||||
job.Fail(errorText)
|
"job_id", job.Id,
|
||||||
err := s.jobRepo.Save(job)
|
"operation_id", opId,
|
||||||
if err != nil {
|
"error_message", errorText)
|
||||||
return err
|
return s.failJob(job, errors.New(errorText), "сбой при распознавании файла")
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Операция завершена, получаем результат
|
// Операция завершена, получаем результат
|
||||||
transcriptionText, err := speechKitService.GetRecognitionText(*job.RecognitionOpID)
|
transcriptionText, err := s.recognizer.GetRecognitionText(opId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
|
||||||
return err
|
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)
|
job.Done(transcriptionText)
|
||||||
|
|
||||||
err = s.jobRepo.Save(job)
|
// Сохраняем задачу в базу
|
||||||
|
err := s.jobRepo.Save(job)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
|
11
lefthook.yml
Normal file
11
lefthook.yml
Normal 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
201
main.go
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -12,9 +14,14 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/ffmpeg"
|
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/sqlite"
|
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"
|
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/controller/worker"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
"github.com/doug-martin/goqu/v9"
|
"github.com/doug-martin/goqu/v9"
|
||||||
@@ -23,54 +30,111 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/pressly/goose/v3"
|
"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() {
|
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 файла
|
// Загружаем переменные окружения из .env файла
|
||||||
if err := godotenv.Load(); err != nil {
|
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 {
|
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil {
|
||||||
log.Fatal("Failed to create data/files directory:", err)
|
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 {
|
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()
|
defer db.Close()
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
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)
|
gq := goqu.New("sqlite3", db)
|
||||||
|
|
||||||
// Запускаем миграции
|
// Запускаем миграции
|
||||||
if err := RunMigrations(db, "migrations"); err != nil {
|
if err := RunMigrations(db, logger); err != nil {
|
||||||
log.Fatal("Failed to run migrations:", err)
|
logger.Error("Failed to run migrations", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Создаем репозитории
|
||||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||||
|
|
||||||
converter := ffmpeg.NewFfmpegConverter()
|
// Создаем адаптеры
|
||||||
|
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||||
|
converter := ffmpegconv.NewFfmpegConverter()
|
||||||
|
|
||||||
transcribeService := service.NewTranscribeService(jobRepo, fileRepo, converter)
|
tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger)
|
||||||
|
if err != nil {
|
||||||
// Создаем воркеры
|
logger.Error("failed to create audio telegram sender", "error", err)
|
||||||
conversionWorker := worker.NewConversionWorker(transcribeService)
|
os.Exit(1)
|
||||||
transcribeWorker := worker.NewTranscribeWorker(transcribeService)
|
|
||||||
checkWorker := worker.NewCheckWorker(transcribeService)
|
|
||||||
|
|
||||||
workers := []worker.Worker{
|
|
||||||
conversionWorker,
|
|
||||||
transcribeWorker,
|
|
||||||
checkWorker,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Создаем контекст для graceful shutdown
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -78,19 +142,57 @@ func main() {
|
|||||||
// Создаем WaitGroup для ожидания завершения всех воркеров
|
// Создаем WaitGroup для ожидания завершения всех воркеров
|
||||||
var wg sync.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 {
|
for _, w := range workers {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(worker worker.Worker) {
|
go func(worker worker.Worker) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
worker.Start(ctx)
|
worker.Start(ctx)
|
||||||
log.Printf("%s stopped", worker.Name())
|
logger.Info("Worker stopped gracefully", "worker", worker.Name())
|
||||||
}(w)
|
}(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Создаем Gin middleware для логирования
|
||||||
|
gin.SetMode(gin.DebugMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(sloggin.New(logger))
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
// Запускаем HTTP сервер для API (создание задач и проверка статуса)
|
// Запускаем HTTP сервер для API (создание задач и проверка статуса)
|
||||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService)
|
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService)
|
||||||
router := gin.Default()
|
|
||||||
|
|
||||||
// Настраиваем роуты только для создания задач и проверки статуса
|
// Настраиваем роуты только для создания задач и проверки статуса
|
||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
@@ -110,9 +212,12 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Добавляем эндпоинт для метрик Prometheus
|
||||||
|
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||||
|
|
||||||
// Создаем HTTP сервер
|
// Создаем HTTP сервер
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":8080",
|
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||||
Handler: router,
|
Handler: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +225,9 @@ func main() {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
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 {
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
log.Println("Transcriber service started with background workers")
|
logger.Info("Transcriber service started with background workers")
|
||||||
log.Println("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
|
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
|
||||||
log.Println("Press Ctrl+C to stop...")
|
logger.Info("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
// Ждем сигнал завершения
|
// Ждем сигнал завершения
|
||||||
<-sigChan
|
<-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 сервера
|
// Создаем контекст с таймаутом для 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()
|
defer shutdownCancel()
|
||||||
|
|
||||||
// Останавливаем HTTP сервер
|
// Останавливаем HTTP сервер
|
||||||
log.Println("Shutting down HTTP server...")
|
logger.Info("Shutting down HTTP server...")
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
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 {
|
} else {
|
||||||
log.Println("HTTP server stopped gracefully")
|
logger.Info("HTTP server stopped gracefully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отменяем контекст для остановки воркеров
|
// Отменяем контекст для остановки воркеров
|
||||||
@@ -160,26 +270,29 @@ func main() {
|
|||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Ждем завершения всех воркеров или таймаута в 10 секунд
|
// Ждем завершения всех воркеров или таймаута
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
log.Println("All workers stopped gracefully")
|
logger.Info("All workers stopped gracefully")
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
|
||||||
log.Println("Timeout reached, forcing shutdown")
|
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 {
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
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)
|
return fmt.Errorf("failed to run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Migrations completed successfully")
|
logger.Info("Migrations completed successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
Reference in New Issue
Block a user