diff --git a/app/api/routes.py b/app/api/routes.py index 76688cb..282a0b4 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -44,7 +44,7 @@ def get_router() -> APIRouter: chat_id, display_name = await svc.auth.handle_callback(code=code, state=state) runner = request.app.state.runtime.telegram_runner if runner is not None: - await runner.send_message(chat_id, f"Spotify подключен: {display_name}\nТеперь можно /generate") + await runner.send_spotify_connected_notice(chat_id, display_name) return """

Spotify connected

diff --git a/app/bot/telegram_bot.py b/app/bot/telegram_bot.py index 2e93536..bc85cd5 100644 --- a/app/bot/telegram_bot.py +++ b/app/bot/telegram_bot.py @@ -1,13 +1,172 @@ from __future__ import annotations import contextlib +from collections.abc import Awaitable, Callable -from telegram import Update -from telegram.ext import Application, CommandHandler, ContextTypes +from telegram import ReplyKeyboardMarkup, Update +from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from app.db.repositories import PlaylistRunRepository, SavedTrackRepository, UserRepository from app.services.app_services import AppServices +Lang = str + + +I18N: dict[str, dict[str, str]] = { + "ru": { + "button_connect": "🔗 Подключить Spotify", + "button_generate": "✨ Сгенерировать", + "button_status": "📊 Статус", + "button_latest": "🎵 Последний плейлист", + "button_sync": "🔄 Обновить лайки", + "button_help": "❓ Помощь", + "button_settings": "⚙️ Настройки", + "button_language": "🌐 Язык / Language", + "button_size_20": "📏 Размер 20", + "button_size_30": "📏 Размер 30", + "button_size_50": "📏 Размер 50", + "button_ratio_60": "🆕 Новые 60%", + "button_ratio_80": "🆕 Новые 80%", + "button_ratio_100": "🆕 Новые 100%", + "button_lang_ru": "🇺🇦 Русский", + "button_lang_en": "🇺🇸 English", + "button_back": "⬅️ Назад", + "start_text": ( + "Я бот для Spotify daily vibe playlist.\n" + "Делаю плейлисты по вашему recent listening + liked tracks, стараюсь давать больше новых треков и меньше повторов.\n\n" + "/connect - привязать Spotify\n" + "/status - статус аккаунта и последнего плейлиста\n" + "/generate - сгенерировать плейлист сейчас\n" + "/latest - показать ссылку на последний плейлист\n" + "/setsize N - размер плейлиста\n" + "/setratio X - доля новых треков (0.5..1.0)\n" + "/sync - обновить лайкнутые треки из Spotify\n" + "/lang [ru|en] - сменить язык" + ), + "help_text": ( + "Я бот для Spotify daily vibe playlist.\n" + "Делаю плейлисты по вашему recent listening + liked tracks, стараюсь давать больше новых треков и меньше повторов.\n\n" + "/connect - привязать Spotify\n" + "/status - статус аккаунта и последнего плейлиста\n" + "/generate - сгенерировать плейлист сейчас\n" + "/latest - показать ссылку на последний плейлист\n" + "/setsize N - размер плейлиста\n" + "/setratio X - доля новых треков (0.5..1.0)\n" + "/sync - обновить лайкнутые треки из Spotify\n" + "/lang [ru|en] - сменить язык" + ), + "connect_open_link": "Открой ссылку и авторизуй Spotify:\n{url}", + "spotify_connected_notice": "Spotify подключен: {display_name}\nТеперь можно /generate", + "user_not_found_start": "Пользователь не найден. Напиши /start", + "no_playlist_yet": "Пока нет сгенерированного плейлиста.", + "generate_wait": "Генерирую плейлист, это может занять 20-60 секунд...", + "error_prefix": "Ошибка: {message}", + "size_usage": "Использование: /setsize 30", + "size_invalid": "Размер должен быть числом от 5 до 100.", + "size_set": "Размер плейлиста установлен: {value}", + "ratio_usage": "Использование: /setratio 0.8", + "ratio_invalid": "Значение должно быть от 0.5 до 1.0", + "ratio_set": "Минимальная доля новых треков: {value:.2f}", + "sync_need_connect": "Сначала /connect", + "sync_done": "Лайкнутые треки обновлены: {count}", + "lang_choose": "Выбери язык интерфейса:", + "lang_set": "Язык интерфейса: {lang_name}", + "lang_invalid": "Поддерживаются только `ru` и `en`.", + "unknown_action": "Не понял команду. Используйте кнопки ниже или /help.", + "main_menu_hint": "Главное меню", + "settings_menu_hint": "Настройки", + "status_yes": "да", + "status_no": "нет", + "status_text": ( + "Connected: {connected}\n" + "Spotify user: {spotify_user}\n" + "Liked tracks cached: {saved_count}\n" + "Playlist size: {playlist_size}\n" + "Min new ratio: {min_new_ratio:.2f}\n" + "Last generated: {last_generated}" + ), + "status_last_run": "Last run: {status}, tracks={tracks}", + "status_last_url": "{url}", + }, + "en": { + "button_connect": "🔗 Connect Spotify", + "button_generate": "✨ Generate", + "button_status": "📊 Status", + "button_latest": "🎵 Latest Playlist", + "button_sync": "🔄 Sync Likes", + "button_help": "❓ Help", + "button_settings": "⚙️ Settings", + "button_language": "🌐 Language / Язык", + "button_size_20": "📏 Size 20", + "button_size_30": "📏 Size 30", + "button_size_50": "📏 Size 50", + "button_ratio_60": "🆕 New 60%", + "button_ratio_80": "🆕 New 80%", + "button_ratio_100": "🆕 New 100%", + "button_lang_ru": "🇺🇦 Русский", + "button_lang_en": "🇺🇸 English", + "button_back": "⬅️ Back", + "start_text": ( + "I am a Spotify daily vibe playlist bot.\n" + "I build playlists from your recent listening + liked tracks and try to keep them fresh with fewer repeats.\n\n" + "/connect - connect Spotify\n" + "/status - account status and latest playlist\n" + "/generate - generate playlist now\n" + "/latest - show latest playlist link\n" + "/setsize N - playlist size\n" + "/setratio X - min new ratio (0.5..1.0)\n" + "/sync - sync liked tracks from Spotify\n" + "/lang [ru|en] - change language" + ), + "help_text": ( + "I am a Spotify daily vibe playlist bot.\n" + "I build playlists from your recent listening + liked tracks and try to keep them fresh with fewer repeats.\n\n" + "/connect - connect Spotify\n" + "/status - account status and latest playlist\n" + "/generate - generate playlist now\n" + "/latest - show latest playlist link\n" + "/setsize N - playlist size\n" + "/setratio X - min new ratio (0.5..1.0)\n" + "/sync - sync liked tracks from Spotify\n" + "/lang [ru|en] - change language" + ), + "connect_open_link": "Open the link and authorize Spotify:\n{url}", + "spotify_connected_notice": "Spotify connected: {display_name}\nNow you can use /generate", + "user_not_found_start": "User not found. Send /start first", + "no_playlist_yet": "No generated playlist yet.", + "generate_wait": "Generating playlist, this can take 20-60 seconds...", + "error_prefix": "Error: {message}", + "size_usage": "Usage: /setsize 30", + "size_invalid": "Size must be a number from 5 to 100.", + "size_set": "Playlist size set to: {value}", + "ratio_usage": "Usage: /setratio 0.8", + "ratio_invalid": "Value must be between 0.5 and 1.0", + "ratio_set": "Minimum new ratio: {value:.2f}", + "sync_need_connect": "Use /connect first", + "sync_done": "Liked tracks synced: {count}", + "lang_choose": "Choose interface language:", + "lang_set": "Interface language: {lang_name}", + "lang_invalid": "Only `ru` and `en` are supported.", + "unknown_action": "I did not understand that. Use the buttons below or /help.", + "main_menu_hint": "Main menu", + "settings_menu_hint": "Settings", + "status_yes": "yes", + "status_no": "no", + "status_text": ( + "Connected: {connected}\n" + "Spotify user: {spotify_user}\n" + "Liked tracks cached: {saved_count}\n" + "Playlist size: {playlist_size}\n" + "Min new ratio: {min_new_ratio:.2f}\n" + "Last generated: {last_generated}" + ), + "status_last_run": "Last run: {status}, tracks={tracks}", + "status_last_url": "{url}", + }, +} + +LANG_NAMES = {"ru": "Русский", "en": "English"} + class TelegramBotRunner: def __init__(self, token: str, session_factory, services: AppServices, app_base_url: str) -> None: @@ -16,8 +175,9 @@ class TelegramBotRunner: self.services = services self.app_base_url = app_base_url.rstrip("/") self.application = Application.builder().token(token).build() - self._setup_handlers() self._running = False + self._chat_lang: dict[str, Lang] = {} + self._setup_handlers() def _setup_handlers(self) -> None: self.application.add_handler(CommandHandler("start", self.start)) @@ -29,6 +189,8 @@ class TelegramBotRunner: self.application.add_handler(CommandHandler("setsize", self.set_size)) self.application.add_handler(CommandHandler("setratio", self.set_ratio)) self.application.add_handler(CommandHandler("sync", self.sync_likes)) + self.application.add_handler(CommandHandler("lang", self.lang)) + self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.on_text_button)) async def start_polling(self) -> None: await self.application.initialize() @@ -53,6 +215,10 @@ class TelegramBotRunner: async def send_message(self, chat_id: str, text: str) -> None: await self.application.bot.send_message(chat_id=int(chat_id), text=text, disable_web_page_preview=False) + async def send_spotify_connected_notice(self, chat_id: str, display_name: str) -> None: + lang = self._chat_lang.get(str(chat_id), "ru") + await self.send_message(chat_id, self._t(lang, "spotify_connected_notice", display_name=display_name)) + async def _ensure_user(self, update: Update): chat = update.effective_chat user = update.effective_user @@ -67,33 +233,192 @@ class TelegramBotRunner: await session.commit() return db_user - async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + def _t(self, lang: Lang, key: str, **kwargs) -> str: + template = I18N.get(lang, I18N["en"]).get(key, I18N["en"].get(key, key)) + return template.format(**kwargs) + + def _detect_lang(self, update: Update) -> Lang: + chat = update.effective_chat + if chat: + cached = self._chat_lang.get(str(chat.id)) + if cached in {"ru", "en"}: + return cached + user = update.effective_user + tg_lang = (getattr(user, "language_code", None) or "").lower() + lang = "ru" if tg_lang.startswith("ru") else "en" + if chat: + self._chat_lang[str(chat.id)] = lang + return lang + + def _set_chat_lang(self, chat_id: str, lang: Lang) -> None: + if lang in {"ru", "en"}: + self._chat_lang[str(chat_id)] = lang + + async def _reply( + self, + update: Update, + text: str, + *, + lang: Lang | None = None, + disable_web_page_preview: bool = False, + reply_markup=None, + ) -> None: + msg = update.effective_message + if msg is None: + return + if reply_markup is None: + lang = lang or self._detect_lang(update) + reply_markup = self._main_menu(lang) + await msg.reply_text(text, disable_web_page_preview=disable_web_page_preview, reply_markup=reply_markup) + + def _main_menu(self, lang: Lang) -> ReplyKeyboardMarkup: + kb = [ + [self._t(lang, "button_connect"), self._t(lang, "button_generate")], + [self._t(lang, "button_status"), self._t(lang, "button_sync")], + [self._t(lang, "button_help"), self._t(lang, "button_settings")], + ] + return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) + + def _settings_menu(self, lang: Lang) -> ReplyKeyboardMarkup: + kb = [ + [ + self._t(lang, "button_size_20"), + self._t(lang, "button_size_30"), + self._t(lang, "button_size_50"), + ], + [ + self._t(lang, "button_ratio_60"), + self._t(lang, "button_ratio_80"), + self._t(lang, "button_ratio_100"), + ], + [self._t(lang, "button_language")], + [self._t(lang, "button_back")], + ] + return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) + + def _language_menu(self, lang: Lang) -> ReplyKeyboardMarkup: + kb = [ + [self._t(lang, "button_lang_ru"), self._t(lang, "button_lang_en")], + [self._t(lang, "button_back")], + ] + return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) + + def _button_labels(self, key: str) -> set[str]: + return {I18N["ru"][key], I18N["en"][key]} + + async def on_text_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + text = (update.effective_message.text if update.effective_message else "") or "" + text = text.strip() + lang = self._detect_lang(update) + + action_handlers: list[tuple[str, Callable[[Update, ContextTypes.DEFAULT_TYPE], Awaitable[None]]]] = [ + ("button_connect", self.connect), + ("button_generate", self.generate), + ("button_status", self.status), + ("button_sync", self.sync_likes), + ("button_help", self.help), + ("button_settings", self.open_settings), + ("button_language", self.lang), + ] + for key, handler in action_handlers: + if text in self._button_labels(key): + await handler(update, context) + return + + if text in self._button_labels("button_lang_ru"): + await self._set_language_from_ui(update, "ru", reply_markup=self._settings_menu("ru")) + return + if text in self._button_labels("button_lang_en"): + await self._set_language_from_ui(update, "en", reply_markup=self._settings_menu("en")) + return + if text in self._button_labels("button_back"): + await self._reply(update, self._t(lang, "main_menu_hint"), lang=lang) + return + + size_map = { + "button_size_20": 20, + "button_size_30": 30, + "button_size_50": 50, + } + for key, value in size_map.items(): + if text in self._button_labels(key): + await self._apply_size(update, value, lang=lang, reply_markup=self._settings_menu(lang)) + return + + ratio_map = { + "button_ratio_60": 0.6, + "button_ratio_80": 0.8, + "button_ratio_100": 1.0, + } + for key, value in ratio_map.items(): + if text in self._button_labels(key): + await self._apply_ratio(update, value, lang=lang, reply_markup=self._settings_menu(lang)) + return + + await self._reply(update, self._t(lang, "unknown_action"), lang=lang) + + async def _set_language_from_ui(self, update: Update, lang: Lang, reply_markup=None) -> None: + chat = update.effective_chat + if not chat: + return + self._set_chat_lang(str(chat.id), lang) await self._ensure_user(update) - await update.message.reply_text( - "Я бот для Spotify daily vibe playlist.\n" - "Команды: /connect /status /generate /latest /setsize 30 /setratio 0.8 /sync" + await self._reply( + update, + self._t(lang, "lang_set", lang_name=LANG_NAMES[lang]), + lang=lang, + reply_markup=reply_markup or self._main_menu(lang), ) + async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await self._ensure_user(update) + await self.help(update, context) + async def help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - await update.message.reply_text( - "/connect - привязать Spotify\n" - "/status - статус аккаунта и последнего плейлиста\n" - "/generate - сгенерировать плейлист сейчас\n" - "/latest - показать ссылку на последний плейлист\n" - "/setsize N - размер плейлиста\n" - "/setratio X - доля новых треков (0.5..1.0)\n" - "/sync - обновить лайкнутые треки из Spotify" + lang = self._detect_lang(update) + await self._reply(update, self._t(lang, "start_text"), lang=lang) + + async def open_settings(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) + await self._reply( + update, + self._t(lang, "settings_menu_hint"), + lang=lang, + reply_markup=self._settings_menu(lang), + ) + + async def lang(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) + args = list(getattr(context, "args", []) or []) + if args: + requested = args[0].strip().lower() + if requested.startswith("ru"): + await self._set_language_from_ui(update, "ru") + return + if requested.startswith("en"): + await self._set_language_from_ui(update, "en") + return + await self._reply(update, self._t(lang, "lang_invalid"), lang=lang) + return + await self._reply( + update, + self._t(lang, "lang_choose"), + lang=lang, + reply_markup=self._language_menu(lang), ) async def connect(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) chat = update.effective_chat user = update.effective_user if not chat: return + self._set_chat_lang(str(chat.id), lang) url = await self.services.auth.create_connect_url(str(chat.id), user.username if user else None) - await update.message.reply_text(f"Открой ссылку и авторизуй Spotify:\n{url}", disable_web_page_preview=True) + await self._reply(update, self._t(lang, "connect_open_link", url=url), lang=lang, disable_web_page_preview=True) async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) chat = update.effective_chat if not chat: return @@ -103,26 +428,29 @@ class TelegramBotRunner: saved = SavedTrackRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user: - await update.message.reply_text("Пользователь не найден. Напиши /start") + await self._reply(update, self._t(lang, "user_not_found_start"), lang=lang) return latest = await runs.latest_for_user(db_user.id) saved_count = await saved.count_for_user(db_user.id) - connected = "yes" if db_user.spotify_refresh_token else "no" - text = ( - f"Connected: {connected}\n" - f"Spotify user: {db_user.spotify_user_id or '-'}\n" - f"Liked tracks cached: {saved_count}\n" - f"Playlist size: {db_user.playlist_size}\n" - f"Min new ratio: {db_user.min_new_ratio:.2f}\n" - f"Last generated: {db_user.last_generated_date or '-'}" + connected = self._t(lang, "status_yes") if db_user.spotify_refresh_token else self._t(lang, "status_no") + text = self._t( + lang, + "status_text", + connected=connected, + spotify_user=db_user.spotify_user_id or "-", + saved_count=saved_count, + playlist_size=db_user.playlist_size, + min_new_ratio=db_user.min_new_ratio, + last_generated=db_user.last_generated_date or "-", ) if latest: - text += f"\nLast run: {latest.status}, tracks={latest.total_tracks}" + text += "\n" + self._t(lang, "status_last_run", status=latest.status, tracks=latest.total_tracks) if latest.playlist_url: - text += f"\n{latest.playlist_url}" - await update.message.reply_text(text, disable_web_page_preview=False) + text += "\n" + self._t(lang, "status_last_url", url=latest.playlist_url) + await self._reply(update, text, lang=lang, disable_web_page_preview=False) async def latest(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) chat = update.effective_chat if not chat: return @@ -130,11 +458,12 @@ class TelegramBotRunner: users = UserRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user or not db_user.latest_playlist_url: - await update.message.reply_text("Пока нет сгенерированного плейлиста.") + await self._reply(update, self._t(lang, "no_playlist_yet"), lang=lang) return - await update.message.reply_text(db_user.latest_playlist_url, disable_web_page_preview=False) + await self._reply(update, db_user.latest_playlist_url, lang=lang, disable_web_page_preview=False) async def generate(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) chat = update.effective_chat if not chat: return @@ -142,62 +471,79 @@ class TelegramBotRunner: users = UserRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user: - await update.message.reply_text("Пользователь не найден. Напиши /start") + await self._reply(update, self._t(lang, "user_not_found_start"), lang=lang) return user_id = db_user.id - await update.message.reply_text("Генерирую плейлист, это может занять 20-60 секунд...") + await self._reply(update, self._t(lang, "generate_wait"), lang=lang) outcome = await self.services.jobs.generate_for_user(user_id=user_id, force=True, notify=False) if outcome.ok: msg = outcome.message if outcome.playlist_url: msg += f"\n{outcome.playlist_url}" - await update.message.reply_text(msg, disable_web_page_preview=False) + await self._reply(update, msg, lang=lang, disable_web_page_preview=False) else: - await update.message.reply_text(f"Ошибка: {outcome.message}") + await self._reply(update, self._t(lang, "error_prefix", message=outcome.message), lang=lang) - async def set_size(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + async def _apply_size(self, update: Update, value: int, *, lang: Lang, reply_markup=None) -> None: chat = update.effective_chat if not chat: return + if value < 5 or value > 100: + await self._reply(update, self._t(lang, "size_invalid"), lang=lang, reply_markup=reply_markup) + return + async with self.session_factory() as session: + users = UserRepository(session) + db_user = await users.get_or_create_by_chat( + str(chat.id), + update.effective_user.username if update.effective_user else None, + ) + db_user.playlist_size = value + await session.commit() + await self._reply(update, self._t(lang, "size_set", value=value), lang=lang, reply_markup=reply_markup) + + async def set_size(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) if not context.args: - await update.message.reply_text("Использование: /setsize 30") + await self._reply(update, self._t(lang, "size_usage"), lang=lang) return try: value = int(context.args[0]) - if value < 5 or value > 100: - raise ValueError except ValueError: - await update.message.reply_text("Размер должен быть числом от 5 до 100.") + await self._reply(update, self._t(lang, "size_invalid"), lang=lang) return - async with self.session_factory() as session: - users = UserRepository(session) - db_user = await users.get_or_create_by_chat(str(chat.id), update.effective_user.username if update.effective_user else None) - db_user.playlist_size = value - await session.commit() - await update.message.reply_text(f"Размер плейлиста установлен: {value}") + await self._apply_size(update, value, lang=lang) - async def set_ratio(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + async def _apply_ratio(self, update: Update, value: float, *, lang: Lang, reply_markup=None) -> None: chat = update.effective_chat if not chat: return - if not context.args: - await update.message.reply_text("Использование: /setratio 0.8") - return - try: - value = float(context.args[0]) - if value < 0.5 or value > 1.0: - raise ValueError - except ValueError: - await update.message.reply_text("Значение должно быть от 0.5 до 1.0") + if value < 0.5 or value > 1.0: + await self._reply(update, self._t(lang, "ratio_invalid"), lang=lang, reply_markup=reply_markup) return async with self.session_factory() as session: users = UserRepository(session) - db_user = await users.get_or_create_by_chat(str(chat.id), update.effective_user.username if update.effective_user else None) + db_user = await users.get_or_create_by_chat( + str(chat.id), + update.effective_user.username if update.effective_user else None, + ) db_user.min_new_ratio = value await session.commit() - await update.message.reply_text(f"Минимальная доля новых треков: {value:.2f}") + await self._reply(update, self._t(lang, "ratio_set", value=value), lang=lang, reply_markup=reply_markup) + + async def set_ratio(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) + if not context.args: + await self._reply(update, self._t(lang, "ratio_usage"), lang=lang) + return + try: + value = float(context.args[0]) + except ValueError: + await self._reply(update, self._t(lang, "ratio_invalid"), lang=lang) + return + await self._apply_ratio(update, value, lang=lang) async def sync_likes(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + lang = self._detect_lang(update) chat = update.effective_chat if not chat: return @@ -205,13 +551,13 @@ class TelegramBotRunner: users = UserRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user: - await update.message.reply_text("Пользователь не найден. Напиши /start") + await self._reply(update, self._t(lang, "user_not_found_start"), lang=lang) return if not db_user.spotify_refresh_token: - await update.message.reply_text("Сначала /connect") + await self._reply(update, self._t(lang, "sync_need_connect"), lang=lang) return access_token = await self.services.auth.ensure_valid_access_token(session, db_user) await self.services.recommendation.sync_saved_tracks(session, db_user, access_token) await session.commit() saved_count = await SavedTrackRepository(session).count_for_user(db_user.id) - await update.message.reply_text(f"Лайкнутые треки обновлены: {saved_count}") + await self._reply(update, self._t(lang, "sync_done", count=saved_count), lang=lang)