Add uk and nl

This commit is contained in:
heboba
2026-02-26 20:40:18 +00:00
parent 756f53ef3b
commit e3ae678fea
3 changed files with 193 additions and 14 deletions

View File

@@ -118,7 +118,7 @@ Notes:
- `/generate` calls `PlaylistJobService.generate_for_user(..., force=True, notify=False)` - `/generate` calls `PlaylistJobService.generate_for_user(..., force=True, notify=False)`
- `/sync` only refreshes liked tracks cache - `/sync` only refreshes liked tracks cache
- each command uses a short-lived DB session from `session_factory` - each command uses a short-lived DB session from `session_factory`
- bot UI supports `ru` and `en` (localized text/buttons) - bot UI supports `ru`, `en`, `uk`, and `nl` (localized text/buttons)
### 3. Service layer ### 3. Service layer

View File

@@ -92,7 +92,7 @@ Recommended:
- `/setsize 30` - playlist size (5..100) - `/setsize 30` - playlist size (5..100)
- `/setratio 0.8` - target new-track ratio (0.5..1.0) - `/setratio 0.8` - target new-track ratio (0.5..1.0)
- `/sync` - force sync liked tracks - `/sync` - force sync liked tracks
- `/lang ru|en` - switch bot language - `/lang ru|en|uk|nl` - switch bot language
## Recommendation Algorithm ## Recommendation Algorithm

View File

@@ -30,6 +30,8 @@ I18N: dict[str, dict[str, str]] = {
"button_ratio_100": "🆕 Новые 100%", "button_ratio_100": "🆕 Новые 100%",
"button_lang_ru": "🇺🇦 Русский", "button_lang_ru": "🇺🇦 Русский",
"button_lang_en": "🇺🇸 English", "button_lang_en": "🇺🇸 English",
"button_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Назад", "button_back": "⬅️ Назад",
"start_text": ( "start_text": (
"Я бот для Spotify daily vibe playlist.\n" "Я бот для Spotify daily vibe playlist.\n"
@@ -41,7 +43,7 @@ I18N: dict[str, dict[str, str]] = {
"/setsize N - размер плейлиста\n" "/setsize N - размер плейлиста\n"
"/setratio X - доля новых треков (0.5..1.0)\n" "/setratio X - доля новых треков (0.5..1.0)\n"
"/sync - обновить лайкнутые треки из Spotify\n" "/sync - обновить лайкнутые треки из Spotify\n"
"/lang [ru|en] - сменить язык" "/lang [ru|en|uk|nl] - сменить язык"
), ),
"help_text": ( "help_text": (
"Я бот для Spotify daily vibe playlist.\n" "Я бот для Spotify daily vibe playlist.\n"
@@ -53,7 +55,7 @@ I18N: dict[str, dict[str, str]] = {
"/setsize N - размер плейлиста\n" "/setsize N - размер плейлиста\n"
"/setratio X - доля новых треков (0.5..1.0)\n" "/setratio X - доля новых треков (0.5..1.0)\n"
"/sync - обновить лайкнутые треки из Spotify\n" "/sync - обновить лайкнутые треки из Spotify\n"
"/lang [ru|en] - сменить язык" "/lang [ru|en|uk|nl] - сменить язык"
), ),
"connect_open_link": "Открой ссылку и авторизуй Spotify:\n{url}", "connect_open_link": "Открой ссылку и авторизуй Spotify:\n{url}",
"spotify_connected_notice": "Spotify подключен: {display_name}\nТеперь можно /generate", "spotify_connected_notice": "Spotify подключен: {display_name}\nТеперь можно /generate",
@@ -71,7 +73,7 @@ I18N: dict[str, dict[str, str]] = {
"sync_done": "Лайкнутые треки обновлены: {count}", "sync_done": "Лайкнутые треки обновлены: {count}",
"lang_choose": "Выбери язык интерфейса:", "lang_choose": "Выбери язык интерфейса:",
"lang_set": "Язык интерфейса: {lang_name}", "lang_set": "Язык интерфейса: {lang_name}",
"lang_invalid": "Поддерживаются только `ru` и `en`.", "lang_invalid": "Поддерживаются только `ru`, `en`, `uk` и `nl`.",
"unknown_action": "Не понял команду. Используйте кнопки ниже или /help.", "unknown_action": "Не понял команду. Используйте кнопки ниже или /help.",
"main_menu_hint": "Главное меню", "main_menu_hint": "Главное меню",
"settings_menu_hint": "Настройки", "settings_menu_hint": "Настройки",
@@ -105,6 +107,8 @@ I18N: dict[str, dict[str, str]] = {
"button_ratio_100": "🆕 New 100%", "button_ratio_100": "🆕 New 100%",
"button_lang_ru": "🇺🇦 Русский", "button_lang_ru": "🇺🇦 Русский",
"button_lang_en": "🇺🇸 English", "button_lang_en": "🇺🇸 English",
"button_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Back", "button_back": "⬅️ Back",
"start_text": ( "start_text": (
"I am a Spotify daily vibe playlist bot.\n" "I am a Spotify daily vibe playlist bot.\n"
@@ -116,7 +120,7 @@ I18N: dict[str, dict[str, str]] = {
"/setsize N - playlist size\n" "/setsize N - playlist size\n"
"/setratio X - min new ratio (0.5..1.0)\n" "/setratio X - min new ratio (0.5..1.0)\n"
"/sync - sync liked tracks from Spotify\n" "/sync - sync liked tracks from Spotify\n"
"/lang [ru|en] - change language" "/lang [ru|en|uk|nl] - change language"
), ),
"help_text": ( "help_text": (
"I am a Spotify daily vibe playlist bot.\n" "I am a Spotify daily vibe playlist bot.\n"
@@ -128,7 +132,7 @@ I18N: dict[str, dict[str, str]] = {
"/setsize N - playlist size\n" "/setsize N - playlist size\n"
"/setratio X - min new ratio (0.5..1.0)\n" "/setratio X - min new ratio (0.5..1.0)\n"
"/sync - sync liked tracks from Spotify\n" "/sync - sync liked tracks from Spotify\n"
"/lang [ru|en] - change language" "/lang [ru|en|uk|nl] - change language"
), ),
"connect_open_link": "Open the link and authorize Spotify:\n{url}", "connect_open_link": "Open the link and authorize Spotify:\n{url}",
"spotify_connected_notice": "Spotify connected: {display_name}\nNow you can use /generate", "spotify_connected_notice": "Spotify connected: {display_name}\nNow you can use /generate",
@@ -146,7 +150,7 @@ I18N: dict[str, dict[str, str]] = {
"sync_done": "Liked tracks synced: {count}", "sync_done": "Liked tracks synced: {count}",
"lang_choose": "Choose interface language:", "lang_choose": "Choose interface language:",
"lang_set": "Interface language: {lang_name}", "lang_set": "Interface language: {lang_name}",
"lang_invalid": "Only `ru` and `en` are supported.", "lang_invalid": "Only `ru`, `en`, `uk`, and `nl` are supported.",
"unknown_action": "I did not understand that. Use the buttons below or /help.", "unknown_action": "I did not understand that. Use the buttons below or /help.",
"main_menu_hint": "Main menu", "main_menu_hint": "Main menu",
"settings_menu_hint": "Settings", "settings_menu_hint": "Settings",
@@ -163,9 +167,164 @@ I18N: dict[str, dict[str, str]] = {
"status_last_run": "Last run: {status}, tracks={tracks}", "status_last_run": "Last run: {status}, tracks={tracks}",
"status_last_url": "{url}", "status_last_url": "{url}",
}, },
"uk": {
"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_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"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|uk|nl] - змінити мову"
),
"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|uk|nl] - змінити мову"
),
"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`, `uk` та `nl`.",
"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}",
},
"nl": {
"button_connect": "🔗 Spotify koppelen",
"button_generate": "✨ Genereren",
"button_status": "📊 Status",
"button_latest": "🎵 Laatste playlist",
"button_sync": "🔄 Likes syncen",
"button_help": "❓ Help",
"button_settings": "⚙️ Instellingen",
"button_language": "🌐 Taal / Language",
"button_size_20": "📏 Grootte 20",
"button_size_30": "📏 Grootte 30",
"button_size_50": "📏 Grootte 50",
"button_ratio_60": "🆕 Nieuw 60%",
"button_ratio_80": "🆕 Nieuw 80%",
"button_ratio_100": "🆕 Nieuw 100%",
"button_lang_ru": "🇺🇦 Русский",
"button_lang_en": "🇺🇸 English",
"button_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Terug",
"start_text": (
"Ik ben een Spotify daily vibe playlist-bot.\n"
"Ik maak playlists op basis van je recent listening + liked tracks en probeer ze fris te houden met minder herhalingen.\n\n"
"/connect - Spotify koppelen\n"
"/status - accountstatus en laatste playlist\n"
"/generate - nu een playlist genereren\n"
"/latest - link naar de laatste playlist tonen\n"
"/setsize N - playlistgrootte\n"
"/setratio X - minimale nieuwe ratio (0.5..1.0)\n"
"/sync - liked tracks uit Spotify synchroniseren\n"
"/lang [ru|en|uk|nl] - taal wijzigen"
),
"help_text": (
"Ik ben een Spotify daily vibe playlist-bot.\n"
"Ik maak playlists op basis van je recent listening + liked tracks en probeer ze fris te houden met minder herhalingen.\n\n"
"/connect - Spotify koppelen\n"
"/status - accountstatus en laatste playlist\n"
"/generate - nu een playlist genereren\n"
"/latest - link naar de laatste playlist tonen\n"
"/setsize N - playlistgrootte\n"
"/setratio X - minimale nieuwe ratio (0.5..1.0)\n"
"/sync - liked tracks uit Spotify synchroniseren\n"
"/lang [ru|en|uk|nl] - taal wijzigen"
),
"connect_open_link": "Open de link en autoriseer Spotify:\n{url}",
"spotify_connected_notice": "Spotify gekoppeld: {display_name}\nJe kunt nu /generate gebruiken",
"user_not_found_start": "Gebruiker niet gevonden. Stuur /start",
"no_playlist_yet": "Nog geen gegenereerde playlist.",
"generate_wait": "Playlist wordt gegenereerd, dit kan 20-60 seconden duren...",
"error_prefix": "Fout: {message}",
"size_usage": "Gebruik: /setsize 30",
"size_invalid": "Grootte moet een getal zijn van 5 t/m 100.",
"size_set": "Playlistgrootte ingesteld op: {value}",
"ratio_usage": "Gebruik: /setratio 0.8",
"ratio_invalid": "Waarde moet tussen 0.5 en 1.0 zijn",
"ratio_set": "Minimale nieuwe ratio: {value:.2f}",
"sync_need_connect": "Gebruik eerst /connect",
"sync_done": "Liked tracks gesynchroniseerd: {count}",
"lang_choose": "Kies de interfacetaal:",
"lang_set": "Interfacetaal: {lang_name}",
"lang_invalid": "Alleen `ru`, `en`, `uk` en `nl` worden ondersteund.",
"unknown_action": "Ik begreep dat niet. Gebruik de knoppen hieronder of /help.",
"main_menu_hint": "Hoofdmenu",
"settings_menu_hint": "Instellingen",
"status_yes": "ja",
"status_no": "nee",
"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"} LANG_NAMES = {"ru": "Русский", "en": "English", "uk": "Українська", "nl": "Nederlands"}
SUPPORTED_LANGS = frozenset(LANG_NAMES.keys())
class TelegramBotRunner: class TelegramBotRunner:
@@ -216,7 +375,7 @@ class TelegramBotRunner:
await self.application.bot.send_message(chat_id=int(chat_id), text=text, disable_web_page_preview=False) 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: async def send_spotify_connected_notice(self, chat_id: str, display_name: str) -> None:
lang = self._chat_lang.get(str(chat_id), "ru") lang = self._chat_lang.get(str(chat_id), "en")
await self.send_message(chat_id, self._t(lang, "spotify_connected_notice", display_name=display_name)) await self.send_message(chat_id, self._t(lang, "spotify_connected_notice", display_name=display_name))
async def _ensure_user(self, update: Update): async def _ensure_user(self, update: Update):
@@ -241,17 +400,24 @@ class TelegramBotRunner:
chat = update.effective_chat chat = update.effective_chat
if chat: if chat:
cached = self._chat_lang.get(str(chat.id)) cached = self._chat_lang.get(str(chat.id))
if cached in {"ru", "en"}: if cached in SUPPORTED_LANGS:
return cached return cached
user = update.effective_user user = update.effective_user
tg_lang = (getattr(user, "language_code", None) or "").lower() tg_lang = (getattr(user, "language_code", None) or "").lower()
lang = "ru" if tg_lang.startswith("ru") else "en" if tg_lang.startswith("uk") or tg_lang.startswith("ua"):
lang = "uk"
elif tg_lang.startswith("ru"):
lang = "ru"
elif tg_lang.startswith("nl"):
lang = "nl"
else:
lang = "en"
if chat: if chat:
self._chat_lang[str(chat.id)] = lang self._chat_lang[str(chat.id)] = lang
return lang return lang
def _set_chat_lang(self, chat_id: str, lang: Lang) -> None: def _set_chat_lang(self, chat_id: str, lang: Lang) -> None:
if lang in {"ru", "en"}: if lang in SUPPORTED_LANGS:
self._chat_lang[str(chat_id)] = lang self._chat_lang[str(chat_id)] = lang
async def _reply( async def _reply(
@@ -299,12 +465,13 @@ class TelegramBotRunner:
def _language_menu(self, lang: Lang) -> ReplyKeyboardMarkup: def _language_menu(self, lang: Lang) -> ReplyKeyboardMarkup:
kb = [ kb = [
[self._t(lang, "button_lang_ru"), self._t(lang, "button_lang_en")], [self._t(lang, "button_lang_ru"), self._t(lang, "button_lang_en")],
[self._t(lang, "button_lang_uk"), self._t(lang, "button_lang_nl")],
[self._t(lang, "button_back")], [self._t(lang, "button_back")],
] ]
return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True)
def _button_labels(self, key: str) -> set[str]: def _button_labels(self, key: str) -> set[str]:
return {I18N["ru"][key], I18N["en"][key]} return {I18N[code][key] for code in SUPPORTED_LANGS if key in I18N.get(code, {})}
async def on_text_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 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 = (update.effective_message.text if update.effective_message else "") or ""
@@ -331,6 +498,12 @@ class TelegramBotRunner:
if text in self._button_labels("button_lang_en"): if text in self._button_labels("button_lang_en"):
await self._set_language_from_ui(update, "en", reply_markup=self._settings_menu("en")) await self._set_language_from_ui(update, "en", reply_markup=self._settings_menu("en"))
return return
if text in self._button_labels("button_lang_uk"):
await self._set_language_from_ui(update, "uk", reply_markup=self._settings_menu("uk"))
return
if text in self._button_labels("button_lang_nl"):
await self._set_language_from_ui(update, "nl", reply_markup=self._settings_menu("nl"))
return
if text in self._button_labels("button_back"): if text in self._button_labels("button_back"):
await self._reply(update, self._t(lang, "main_menu_hint"), lang=lang) await self._reply(update, self._t(lang, "main_menu_hint"), lang=lang)
return return
@@ -398,6 +571,12 @@ class TelegramBotRunner:
if requested.startswith("en"): if requested.startswith("en"):
await self._set_language_from_ui(update, "en") await self._set_language_from_ui(update, "en")
return return
if requested.startswith("uk") or requested.startswith("ua"):
await self._set_language_from_ui(update, "uk")
return
if requested.startswith("nl"):
await self._set_language_from_ui(update, "nl")
return
await self._reply(update, self._t(lang, "lang_invalid"), lang=lang) await self._reply(update, self._t(lang, "lang_invalid"), lang=lang)
return return
await self._reply( await self._reply(