Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c6cc64a
Add support for proxy with weird symbols
karilaa-dev Jan 13, 2026
98ea436
Fix proxy for yt-dlp
karilaa-dev Jan 13, 2026
c4e91b5
Add logging configuration and enhance proxy logging
karilaa-dev Jan 13, 2026
0b8fd43
Add proxies.txt to .gitignore
karilaa-dev Jan 13, 2026
2bc71a9
Enhance URL resolution to support additional TikTok short link formats
karilaa-dev Jan 13, 2026
0eecc36
Implement unified curl_cffi flow for TikTok video and music extractio…
karilaa-dev Jan 13, 2026
9653208
Refactor image download to use curl_cffi with browser impersonation a…
karilaa-dev Jan 14, 2026
fd3e659
Enhance proxy URL matching to support additional schemes and improve …
karilaa-dev Jan 14, 2026
cab2424
Optimize video download handling by replacing list accumulation with …
karilaa-dev Jan 14, 2026
26cf454
Add debug logging for TikTok extraction failures and enhance proxy ro…
karilaa-dev Jan 14, 2026
63db752
Fix login path check and enhance proxy URL handling for authentication
karilaa-dev Jan 14, 2026
48eb28e
Refactor TikTok extraction to use synchronous context for downloading…
karilaa-dev Jan 14, 2026
2c22245
Refactor TikTok extraction to use curl_cffi for media downloads and i…
karilaa-dev Jan 14, 2026
21b0751
Refactor queue management to implement sequential per-user processing…
karilaa-dev Jan 14, 2026
8e4a187
Refactor TikTokClient to implement async-safe session management and …
karilaa-dev Jan 14, 2026
f92c774
Refactor logging level for HTML preview and enhance queue count clean…
karilaa-dev Jan 14, 2026
7fac66b
Refactor data extraction patterns in TikTok client for improved perfo…
karilaa-dev Jan 14, 2026
1aab51e
Refactor TikTok client to load cookies from configuration and define …
karilaa-dev Jan 14, 2026
5c8edd4
Add TikTokInvalidLinkError and enhance video link validation in TikTo…
karilaa-dev Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ DAILY_STATS_MESSAGE_ID=24
BOTSTAT=abcdefg12345
MONETAG_URL=https://example.com/your-monetag-link/

# Logging settings (optional)
# LOG_LEVEL=INFO # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL

# yt-dlp settings (optional)
# YTDLP_COOKIES=cookies.txt

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ config.ini
sqlite*.db
LICENSE.md
cookies.txt
proxies.txt

#extensions
*.log
Expand Down
36 changes: 36 additions & 0 deletions data/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import logging
import os
from json import loads as json_loads
from pathlib import Path
Expand Down Expand Up @@ -44,6 +45,21 @@ def _parse_json_list(key: str) -> list[int]:
return []


def _parse_log_level(key: str, default: str = "INFO") -> int:
"""Parse an environment variable as a logging level, returning default if unset/invalid."""
level_map = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}

default_level = level_map.get(default.upper().strip(), logging.INFO)
value = os.getenv(key, default).upper().strip()
return level_map.get(value, default_level)


class BotConfig(TypedDict):
"""Type definition for bot configuration."""

Expand Down Expand Up @@ -103,6 +119,18 @@ class PerformanceConfig(TypedDict):
max_video_duration: int # Maximum video duration in seconds (0 = no limit)


class TikTokConfig(TypedDict):
"""Type definition for TikTok extraction configuration."""

cookies_file: str # Path to Netscape-format cookies file (optional)


class LoggingConfig(TypedDict):
"""Type definition for logging configuration."""

log_level: int # Logging level (e.g., logging.INFO, logging.DEBUG)


class Config(TypedDict):
"""Type definition for the main configuration."""

Expand All @@ -112,6 +140,8 @@ class Config(TypedDict):
queue: QueueConfig
proxy: ProxyConfig
performance: PerformanceConfig
tiktok: TikTokConfig
logging: LoggingConfig


config: Config = {
Expand Down Expand Up @@ -159,6 +189,12 @@ class Config(TypedDict):
),
"max_video_duration": _parse_int_env("MAX_VIDEO_DURATION", 1800), # 30 minutes
},
"tiktok": {
"cookies_file": os.getenv("YTDLP_COOKIES", ""),
},
"logging": {
"log_level": _parse_log_level("LOG_LEVEL", "INFO"),
},
}

admin_ids: list[int] = config["bot"]["admin_ids"]
Expand Down
35 changes: 23 additions & 12 deletions data/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,32 @@
from data.config import config
from data.database import init_db, initialize_database_components

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)-5.5s] %(message)s",
handlers=[
# logging.FileHandler("bot.log"),
logging.StreamHandler()
])
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING)
logging.getLogger('apscheduler.scheduler').propagate = False
logging.getLogger('aiogram').setLevel(logging.WARNING)

local_server = AiohttpSession(api=TelegramAPIServer.from_base(config["bot"]["tg_server"]))
bot = Bot(token=config["bot"]["token"], session=local_server, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
logging.basicConfig(
level=config["logging"]["log_level"],
format="%(asctime)s [%(levelname)-5.5s] %(message)s",
handlers=[
# logging.FileHandler("bot.log"),
logging.StreamHandler()
],
)
logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING)
logging.getLogger("apscheduler.scheduler").propagate = False
logging.getLogger("aiogram").setLevel(logging.WARNING)

local_server = AiohttpSession(
api=TelegramAPIServer.from_base(config["bot"]["tg_server"])
)
bot = Bot(
token=config["bot"]["token"],
session=local_server,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)

dp = Dispatcher(storage=MemoryStorage())

scheduler = AsyncIOScheduler(timezone="America/Los_Angeles", job_defaults={"coalesce": True})
scheduler = AsyncIOScheduler(
timezone="America/Los_Angeles", job_defaults={"coalesce": True}
)


async def setup_db(db_url: str):
Expand Down
1 change: 1 addition & 0 deletions data/locale/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>تم حذف هذا الفيديو من قبل المنشئ.</b>",
"error_private": "<b>هذا الفيديو خاص ولا يمكن الوصول إليه.</b>",
"error_region": "<b>هذا الفيديو غير متوفر في منطقتك.</b>",
"error_invalid_link": "<b>رابط الفيديو غير صالح.</b>\nيرجى التحقق من الرابط والمحاولة مرة أخرى.",
"error_network": "<b>حدث خطأ في الشبكة.</b>\nيرجى المحاولة مرة أخرى.",
"error_rate_limit": "<b>طلبات كثيرة جدًا.</b>\nيرجى الانتظار قليلاً والمحاولة مرة أخرى.",
"error_too_long": "<b>هذا الفيديو طويل جدًا.</b>\nالحد الأقصى المسموح به هو 30 دقيقة.",
Expand Down
1 change: 1 addition & 0 deletions data/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>This video has been deleted by the creator.</b>",
"error_private": "<b>This video is private and cannot be accessed.</b>",
"error_region": "<b>This video is not available in your region.</b>",
"error_invalid_link": "<b>Invalid video link.</b>\nPlease check the link and try again.",
"error_network": "<b>Network error occurred.</b>\nPlease try again.",
"error_rate_limit": "<b>Too many requests.</b>\nPlease wait a moment and try again.",
"error_too_long": "<b>This video is too long.</b>\nMaximum allowed duration is 30 minutes.",
Expand Down
1 change: 1 addition & 0 deletions data/locale/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>यह वीडियो निर्माता द्वारा हटा दिया गया है।</b>",
"error_private": "<b>यह वीडियो निजी है और एक्सेस नहीं किया जा सकता।</b>",
"error_region": "<b>यह वीडियो आपके क्षेत्र में उपलब्ध नहीं है।</b>",
"error_invalid_link": "<b>अमान्य वीडियो लिंक।</b>\nकृपया लिंक जांचें और फिर से प्रयास करें।",
"error_network": "<b>नेटवर्क त्रुटि हुई।</b>\nकृपया फिर से प्रयास करें।",
"error_rate_limit": "<b>बहुत अधिक अनुरोध।</b>\nकृपया कुछ देर प्रतीक्षा करें और फिर से प्रयास करें।",
"error_too_long": "<b>यह वीडियो बहुत लंबा है।</b>\nअधिकतम अनुमत अवधि 30 मिनट है।",
Expand Down
1 change: 1 addition & 0 deletions data/locale/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>Video ini sudah dihapus oleh pembuatnya.</b>",
"error_private": "<b>Video ini privat dan tidak bisa diakses.</b>",
"error_region": "<b>Video ini tidak tersedia di wilayahmu.</b>",
"error_invalid_link": "<b>Tautan video tidak valid.</b>\nSilakan periksa tautan dan coba lagi.",
"error_network": "<b>Terjadi kesalahan jaringan.</b>\nSilakan coba lagi.",
"error_rate_limit": "<b>Terlalu banyak permintaan.</b>\nSilakan tunggu sebentar dan coba lagi.",
"error_too_long": "<b>Video ini terlalu panjang.</b>\nDurasi maksimum yang diizinkan adalah 30 menit.",
Expand Down
1 change: 1 addition & 0 deletions data/locale/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>Это видео было удалено автором.</b>",
"error_private": "<b>Это видео приватное и недоступно.</b>",
"error_region": "<b>Это видео недоступно в вашем регионе.</b>",
"error_invalid_link": "<b>Неверная ссылка на видео.</b>\nПожалуйста, проверьте ссылку и попробуйте снова.",
"error_network": "<b>Произошла сетевая ошибка.</b>\nПожалуйста, попробуйте снова.",
"error_rate_limit": "<b>Слишком много запросов.</b>\nПожалуйста, подождите немного и попробуйте снова.",
"error_too_long": "<b>Это видео слишком длинное.</b>\nМаксимальная продолжительность — 30 минут.",
Expand Down
1 change: 1 addition & 0 deletions data/locale/so.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>Fiidiyowgan waxaa tirtiray abuurahiisa.</b>",
"error_private": "<b>Fiidiyowgan waa qarsoon yahay mana la heli karo.</b>",
"error_region": "<b>Fiidiyowgan laguma heli karo aagaaga.</b>",
"error_invalid_link": "<b>Link-a fiidiyowgu ma shaqeynayo.</b>\nFadlan hubi link-a oo isku day mar kale.",
"error_network": "<b>Khalad shabakad ayaa dhacay.</b>\nFadlan isku day mar kale.",
"error_rate_limit": "<b>Codsiyo badan ayaa la sameeyay.</b>\nFadlan sug wakhti yar oo isku day mar kale.",
"error_too_long": "<b>Fiidiyowgan waa dheer yahay.</b>\nMudda ugu badan ee la ogol yahay waa 30 daqiiqo.",
Expand Down
1 change: 1 addition & 0 deletions data/locale/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>Це відео було видалено автором.</b>",
"error_private": "<b>Це відео приватне і недоступне.</b>",
"error_region": "<b>Це відео недоступне у вашому регіоні.</b>",
"error_invalid_link": "<b>Невірне посилання на відео.</b>\nБудь ласка, перевірте посилання та спробуйте ще раз.",
"error_network": "<b>Сталася мережева помилка.</b>\nБудь ласка, спробуйте ще раз.",
"error_rate_limit": "<b>Занадто багато запитів.</b>\nБудь ласка, зачекайте трохи і спробуйте знову.",
"error_too_long": "<b>Це відео занадто довге.</b>\nМаксимальна тривалість — 30 хвилин.",
Expand Down
1 change: 1 addition & 0 deletions data/locale/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"error_deleted": "<b>Video này đã bị người tạo xóa.</b>",
"error_private": "<b>Video này ở chế độ riêng tư và không thể truy cập.</b>",
"error_region": "<b>Video này không khả dụng ở khu vực của bạn.</b>",
"error_invalid_link": "<b>Liên kết video không hợp lệ.</b>\nVui lòng kiểm tra liên kết và thử lại.",
"error_network": "<b>Đã xảy ra lỗi mạng.</b>\nVui lòng thử lại.",
"error_rate_limit": "<b>Quá nhiều yêu cầu.</b>\nVui lòng đợi một chút và thử lại.",
"error_too_long": "<b>Video này quá dài.</b>\nThời lượng tối đa cho phép là 30 phút.",
Expand Down
12 changes: 3 additions & 9 deletions handlers/get_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ async def update_inline_status(attempt: int):
await asyncio.sleep(0.5)

try:
# Use queue with bypass_user_limit=True for inline downloads
# Inline downloads bypass the per-user queue limit
async with queue.info_queue(user_id, bypass_user_limit=True) as acquired:
# Use queue with bypass=True for inline downloads
# Inline downloads bypass the per-user queue entirely
async with queue.user_queue(user_id, bypass=True) as acquired:
if not acquired:
# This shouldn't happen with bypass, but handle anyway
await bot.edit_message_text(
Expand All @@ -148,8 +148,6 @@ async def update_inline_status(attempt: int):
)

if video_info.is_slideshow: # Process image
# Clean up resources before returning (close YDL context)
video_info.close()
return await bot.edit_message_text(
inline_message_id=message_id, text=locale[lang]["only_video_supported"]
)
Expand All @@ -170,10 +168,6 @@ async def update_inline_status(attempt: int):
full_name=full_name,
)

# Clean up video_info resources (videos already closed in video() method,
# but call close() for safety - it's idempotent)
video_info.close()

try: # Try to write log into database
# Write log into database
await add_video(
Expand Down
99 changes: 29 additions & 70 deletions handlers/get_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from aiogram import Router, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import Message, ReactionTypeEmoji, CallbackQuery
from aiogram.types import Message, ReactionTypeEmoji
from aiogram.utils.keyboard import InlineKeyboardBuilder

from data.config import locale, second_ids, monetag_url, config
Expand All @@ -25,19 +25,6 @@
# Note: Must use valid Telegram reaction emojis (🔄 and ⏳ are not valid)
RETRY_EMOJIS = ["👀", "🤔", "🙏"]

# Callback data prefix for retry button
RETRY_CALLBACK_PREFIX = "retry_video"


def try_again_button(lang: str):
"""Create a 'Try Again' button for queue full error."""
keyb = InlineKeyboardBuilder()
keyb.button(
text=locale[lang]["try_again_button"],
callback_data=RETRY_CALLBACK_PREFIX,
)
return keyb.as_markup()


@video_router.message(F.text)
async def send_tiktok_video(message: Message):
Expand All @@ -63,12 +50,12 @@ async def send_tiktok_video(message: Message):
else: # Set lang and file mode if in DB
lang, file_mode = settings

# Get queue manager and retry config
# Get queue manager
queue = QueueManager.get_instance()
retry_config = config["queue"]

try:
# Check if link is valid
# Check if link is valid (BEFORE queue - quick check)
video_link, is_mobile = await api.regex_check(message.text)
# If not valid
if video_link is None:
Expand All @@ -77,13 +64,16 @@ async def send_tiktok_video(message: Message):
await message.reply(locale[lang]["link_error"])
return

# Check per-user queue limit before proceeding
user_queue_count = queue.get_user_queue_count(message.chat.id)
if user_queue_count >= retry_config["max_user_queue_size"]:
# Check queue limit BEFORE showing reaction
if (
queue.get_user_queue_count(message.chat.id)
>= retry_config["max_user_queue_size"]
):
if not group_chat:
await message.reply(
locale[lang]["error_queue_full"].format(user_queue_count),
reply_markup=try_again_button(lang),
locale[lang]["error_queue_full"].format(
queue.get_user_queue_count(message.chat.id)
)
)
return

Expand Down Expand Up @@ -114,18 +104,22 @@ async def update_retry_status(attempt: int):
except Exception as e:
logging.warning(f"Failed to update retry emoji to {emoji}: {e}")

# Acquire info queue slot with per-user limit
async with queue.info_queue(message.chat.id) as acquired:
# ENTIRE operation inside queue - waits silently for turn
async with queue.user_queue(message.chat.id) as acquired:
if not acquired:
# User limit exceeded (shouldn't happen due to pre-check, but handle anyway)
# Queue full (race condition - another request got in first)
if status_message:
await status_message.delete()
else:
try:
await message.react([])
except TelegramBadRequest:
pass
if not group_chat:
await message.reply(
locale[lang]["error_queue_full"].format(
queue.get_user_queue_count(message.chat.id)
),
reply_markup=try_again_button(lang),
)
)
return

Expand All @@ -150,19 +144,16 @@ async def update_retry_status(attempt: int):
await message.reply(get_error_message(e, lang))
return

# Successfully got video info - show processing emoji
if not status_message:
try:
await message.react(
[ReactionTypeEmoji(emoji="👨‍💻")], disable_notification=True
)
except TelegramBadRequest:
logging.debug("Failed to set processing reaction")
# Successfully got video info - show processing emoji
if not status_message:
try:
await message.react(
[ReactionTypeEmoji(emoji="👨‍💻")], disable_notification=True
)
except TelegramBadRequest:
logging.debug("Failed to set processing reaction")

# Use try/finally to ensure video_info resources are cleaned up
# (especially download context for slideshows)
try:
# Send video/images (no global send queue - per-user limit only)
# Send video/images (INSIDE queue - next request waits)
if video_info.is_slideshow: # Process images
# Send upload image action
await bot.send_chat_action(
Expand Down Expand Up @@ -260,9 +251,6 @@ async def update_retry_status(attempt: int):
except Exception as e:
logging.error("Can't write into database")
logging.error(e)
finally:
# Clean up video_info resources (closes YDL context for slideshows)
video_info.close()

except Exception as e: # If something went wrong
error_text = error_catch(e)
Expand All @@ -283,32 +271,3 @@ async def update_retry_status(attempt: int):
logging.debug("Failed to update UI during error cleanup")
except Exception as cleanup_err:
logging.warning(f"Unexpected error during cleanup: {cleanup_err}")


@video_router.callback_query(F.data == RETRY_CALLBACK_PREFIX)
async def handle_retry_callback(callback: CallbackQuery):
"""Handle 'Try Again' button click for queue full error."""
# Ensure callback.message exists and is accessible
if not callback.message or not hasattr(callback.message, "reply_to_message"):
await callback.answer("Message not accessible", show_alert=True)
return

# Get the original message that contains the TikTok link
original_message = callback.message.reply_to_message

if not original_message or not original_message.text:
await callback.answer("Original message not found", show_alert=True)
return

# Delete the error message with the button
try:
if hasattr(callback.message, "delete"):
await callback.message.delete()
except TelegramBadRequest:
logging.debug("Retry button message already deleted")

# Answer the callback to remove loading state
await callback.answer()

# Re-process the original message
await send_tiktok_video(original_message)
Loading