From aee6623ef467e22ae72c3ac85e0c4ba6d113f809 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Fri, 27 Feb 2026 23:11:17 -0800 Subject: [PATCH 1/4] Update codebase map with Instagram download support Add Instagram API module, link dispatcher, and handler documentation to CODEBASE_MAP.md and AGENTS.md following the Instagram download support addition in #66. --- AGENTS.md | 13 ++- docs/CODEBASE_MAP.md | 235 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 196 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5905ecc..0883b71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,16 +4,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Codebase Overview -**tt-bot** is a Telegram bot for downloading TikTok videos, slideshows, and audio without watermarks. Production-grade with proxy rotation, queue management, 3-part retry strategy, and multilingual support. +**tt-bot** is a Telegram bot for downloading TikTok and Instagram videos, slideshows, and audio without watermarks. Production-grade with proxy rotation, queue management, 3-part retry strategy, and multilingual support. -**Stack:** Python 3.13, aiogram 3.24, yt-dlp, curl_cffi, SQLAlchemy 2.0, asyncpg +**Stack:** Python 3.13, aiogram 3.24, yt-dlp, curl_cffi, aiohttp, SQLAlchemy 2.0, asyncpg **Structure:** - `main.py` - Main bot entry point - `tiktok_api/` - TikTok extraction (client.py is the core with 3-part retry) -- `handlers/` - Telegram message handlers +- `instagram_api/` - Instagram extraction via RapidAPI +- `handlers/` - Telegram message handlers (`link_dispatcher.py` routes Instagram vs TikTok) +- `media_types/` - Media sending/processing package (shared across sources) - `data/` - Configuration, database, localization -- `misc/` - Queue management, media processing utilities +- `misc/` - Queue management, utilities - `stats/` - Statistics bot and graphs For detailed architecture, see [docs/CODEBASE_MAP.md](docs/CODEBASE_MAP.md). @@ -55,5 +57,6 @@ uv sync - **New command**: Add handler in `handlers/`, register router in `main.py` - **New language**: Create `data/locale/XX.json` (auto-detected) -- **New TikTok error type**: Add to `tiktok_api/exceptions.py`, register in `media_types/errors.py` via `register_error_mapping()` +- **New error type**: Add to `*_api/exceptions.py`, register via `register_error_mapping()` in package `__init__.py` +- **New video source**: Create `source_api/` module → add URL filter in `handlers/link_dispatcher.py` → create handler - **New unsupported content handler**: Add to `handlers/get_video.py` diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index b71c1e3..e911dc0 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -1,19 +1,19 @@ --- -last_mapped: 2026-02-27T23:59:45Z -total_files: 68 -total_tokens: 72186 +last_mapped: 2026-02-28T07:06:13Z +total_files: 74 +total_tokens: 75784 --- # Codebase Map -> Auto-generated by Cartographer. Last mapped: 2026-02-27 +> Auto-generated by Cartographer. Last mapped: 2026-02-28 ## System Overview -**tt-bot** is a production-grade Telegram bot for downloading TikTok videos, slideshows, and audio without watermarks. Built with Python 3.13, aiogram 3.x, and yt-dlp. +**tt-bot** is a production-grade Telegram bot for downloading TikTok and Instagram videos, slideshows, and audio without watermarks. Built with Python 3.13, aiogram 3.x, yt-dlp, and RapidAPI. **Version:** 4.5.0 -**Stack:** Python 3.13, aiogram 3.24, yt-dlp, curl_cffi, SQLAlchemy 2.0, asyncpg, aiohttp +**Stack:** Python 3.13, aiogram 3.24, yt-dlp, curl_cffi, aiohttp, SQLAlchemy 2.0, asyncpg ```mermaid graph TB @@ -24,7 +24,9 @@ graph TB end subgraph Handlers["Telegram Handlers"] - Video[get_video.py
Video Downloads] + LinkDisp[link_dispatcher.py
URL Routing] + IGHandler[instagram.py
Instagram Downloads] + Video[get_video.py
TikTok Downloads] Music[get_music.py
Audio Extraction] Inline[get_inline.py
Inline Mode] User[user.py
User Commands] @@ -33,6 +35,12 @@ graph TB Advert[advert.py
Broadcasts] end + subgraph Instagram["Instagram API"] + IGClient[client.py
RapidAPI Client] + IGModels[models.py
InstagramMediaInfo] + IGExc[exceptions.py
Error Types] + end + subgraph TikTok["TikTok API"] Client[client.py
Main Client + 3-Part Retry] Proxy[proxy_manager.py
Proxy Rotation] @@ -71,10 +79,17 @@ graph TB Main --> Handlers Stats --> StatsM - Handlers --> TikTok + LinkDisp --> IGHandler + LinkDisp -.->|falls through| Video + IGHandler --> Instagram + IGHandler --> MediaPkg + Video --> TikTok Handlers --> MediaPkg Handlers --> Misc + Inline --> TikTok + Inline --> Instagram TikTok --> Proxy + IGClient --> HttpSes Handlers --> Data StatsM --> Data SendVid --> HttpSes @@ -85,6 +100,7 @@ graph TB SendMus --> HttpSes Storage --> HttpSes ErrMap --> TikTok + ErrMap --> Instagram ``` ## 3-Part Retry Strategy @@ -158,10 +174,17 @@ tt-bot/ │ ├── exceptions.py # Exception hierarchy (9 error types) │ ├── models.py # VideoInfo, MusicInfo dataclasses │ └── proxy_manager.py # Round-robin proxy rotation +├── instagram_api/ # Instagram extraction module (RapidAPI) +│ ├── __init__.py # Public exports + error mapping registration +│ ├── client.py # InstagramClient + URL regex +│ ├── exceptions.py # Exception hierarchy (4 error types) +│ └── models.py # InstagramMediaItem, InstagramMediaInfo ├── handlers/ # Telegram message handlers -│ ├── get_video.py # Video/slideshow download handler + unsupported content handlers +│ ├── link_dispatcher.py # URL routing: Instagram vs TikTok +│ ├── instagram.py # Instagram download handler +│ ├── get_video.py # TikTok download handler + unsupported content handlers │ ├── get_music.py # Audio extraction handler -│ ├── get_inline.py # Inline mode handler +│ ├── get_inline.py # Inline mode handler (TikTok + Instagram) │ ├── user.py # /start, /mode commands │ ├── admin.py # Admin commands │ ├── advert.py # Broadcast system @@ -252,37 +275,85 @@ _curl_session_pool: dict[proxy, CurlAsyncSession] # Per-proxy curl sessions (100 --- +### Instagram API (`instagram_api/`) + +**Purpose:** Instagram media extraction via RapidAPI — no cookies, proxies, or browser impersonation needed + +| File | Purpose | Tokens | +|------|---------|--------| +| client.py | InstagramClient HTTP client + URL regex | 499 | +| models.py | InstagramMediaItem, InstagramMediaInfo dataclasses | 254 | +| exceptions.py | Exception hierarchy (4 error types) | 70 | +| __init__.py | Public exports + self-registering error mappings | 153 | + +**Key Classes:** +- `InstagramClient`: Stateless client, calls RapidAPI endpoint via shared aiohttp session +- `InstagramMediaInfo`: Response container with computed properties (`is_video`, `is_carousel`, `image_urls`, `video_url`, `thumbnail_url`) +- `InstagramMediaItem`: Individual media item (type, url, thumbnail, quality) + +**Exceptions:** +- `InstagramError`: Base class +- `InstagramNetworkError`: Connection/HTTP failures +- `InstagramNotFoundError`: 404 or empty media +- `InstagramRateLimitError`: HTTP 429 + +**Self-Registering Error Mappings:** +Importing `instagram_api` automatically calls `register_error_mapping()` for: +- `InstagramNotFoundError` → `"error_instagram_not_found"` +- `InstagramNetworkError` → `"error_network"` (shared key with TikTok) +- `InstagramRateLimitError` → `"error_rate_limit"` (shared key with TikTok) + +**URL Regex:** `r"https?://(?:www\.)?instagram\.com/(?:p|reels?|reel|tv|stories)/[\w-]+"` + +**Config:** `config["instagram"]["rapidapi_key"]` from `RAPIDAPI_KEY` env var + +--- + ### Handlers (`handlers/`) **Purpose:** Telegram message/callback handlers using aiogram routers | File | Purpose | Tokens | |------|---------|--------| -| get_video.py | Video/slideshow download + unsupported content handlers | 2,675 | -| get_inline.py | Inline query handling with minimal loading indicator | 1,334 | -| get_music.py | Audio extraction | 972 | +| get_video.py | TikTok download + unsupported content handlers | 2,674 | +| instagram.py | Instagram video/carousel download handler | 1,935 | +| get_inline.py | Inline queries (TikTok + Instagram) | 1,731 | +| get_music.py | Audio extraction | 971 | | advert.py | Admin broadcast system | 852 | +| link_dispatcher.py | URL routing: Instagram filter → TikTok fallthrough | 791 | | lang.py | Language selection | 469 | | user.py | /start, /mode commands | 384 | -| admin.py | Admin commands (/msg, /export, /botstat) | 348 | +| admin.py | Admin commands (/msg, /export, /botstat) | 253 | + +**Router Order (critical — registered in `main.py`):** +``` +user_router → lang_router → admin_router → advert_router + → link_router (Instagram URLs intercepted here) + → video_router (TikTok — only non-Instagram text reaches here) + → music_router → inline_router +``` **Key Routes:** +- `link_router`: Custom `IsInstagramLink` filter intercepts Instagram URLs before TikTok handler - `video_router`: Handles TikTok URLs in messages - `music_router`: Handles music button callbacks -- `inline_router`: Handles inline queries and chosen results +- `inline_router`: Handles inline queries and chosen results (TikTok + Instagram) - `user_router`: /start, /mode - `lang_router`: /lang and language callbacks - `admin_router`: Admin-only commands - `advert_router`: Broadcast management -**Unsupported Content Handlers (NEW):** +**URL Dispatch Pattern (`link_dispatcher.py`):** +- `IsInstagramLink(Filter)`: regex match on message text → injects `instagram_url` as handler parameter +- Match → `handle_instagram_link()` (lazy import to avoid circular deps) +- No match → falls through to `video_router` (TikTok regex check) + +**Unsupported Content Handlers (in `get_video.py`):** - `handle_video_upload()`: Inform users to send links, not videos (F.video | F.video_note) -- `handle_image_upload()`: Inform users about TikTok links only (F.photo) +- `handle_image_upload()`: Inform users about links only (F.photo) - `handle_voice_upload()`: Same for voice/audio (F.voice | F.audio) - `handle_unsupported_content()`: Catch-all for unsupported content types -**Note:** Handlers call `video()` and `music()` directly - retry logic is built-in. - --- ### Data Layer (`data/`) @@ -291,7 +362,7 @@ _curl_session_pool: dict[proxy, CurlAsyncSession] # Per-proxy curl sessions (100 | File | Purpose | Tokens | |------|---------|--------| -| config.py | Load config + RetryConfig from env vars | 1,581 | +| config.py | Load config + RetryConfig + InstagramConfig from env vars | 1,631 | | db_service.py | Repository pattern database operations | 1,609 | | database.py | SQLAlchemy async engine/session | 517 | | loader.py | Bot/dispatcher/scheduler initialization | 293 | @@ -310,6 +381,7 @@ _curl_session_pool: dict[proxy, CurlAsyncSession] # Per-proxy curl sessions (100 - `retry`: URL/info/download retry counts - `proxy`: Proxy file, rotation settings - `performance`: Streaming threshold, max duration +- `instagram`: RapidAPI key --- @@ -319,14 +391,14 @@ _curl_session_pool: dict[proxy, CurlAsyncSession] # Per-proxy curl sessions (100 | File | Purpose | Tokens | |------|---------|--------| -| send_images.py | Image download and slideshow sending with retry | 1,982 | -| image_processing.py | PIL/HEIF conversion, format detection, ProcessPoolExecutor | 674 | -| send_video.py | Video sending (regular + inline) with thumbnail | 658 | -| http_session.py | Shared aiohttp session, URL/thumbnail download | 573 | -| storage.py | Upload to Telegram storage channel (inline messages) | 454 | -| errors.py | Extensible error-to-message mapping | 345 | -| send_music.py | Music sending with cover art | 285 | -| ui.py | Keyboard and caption helpers | 123 | +| send_images.py | TikTok slideshow download and sending with retry | 1,291 | +| image_processing.py | PIL/HEIF conversion, format detection, ProcessPoolExecutor | 505 | +| send_video.py | Video sending (regular + inline) with thumbnail | 607 | +| http_session.py | Shared aiohttp session, URL/thumbnail download | 353 | +| storage.py | Upload to Telegram storage channel (inline messages) | 418 | +| errors.py | Extensible error-to-message mapping (TikTok + Instagram) | 261 | +| send_music.py | Music sending with cover art | 257 | +| ui.py | Keyboard and caption helpers | 139 | | __init__.py | Re-exports public API (flat import surface) | 123 | **Dependency Graph (no cycles):** @@ -506,28 +578,83 @@ sequenceDiagram Handler->>DB: Log music download ``` -### Inline Mode Flow +### Instagram Download Flow + +```mermaid +sequenceDiagram + participant User + participant LinkDisp as link_dispatcher.py + participant Queue as QueueManager + participant Handler as instagram.py + participant Client as InstagramClient + participant RapidAPI + participant ImgProc as image_processing + participant DB + + User->>LinkDisp: Send Instagram URL + LinkDisp->>LinkDisp: IsInstagramLink filter match + LinkDisp->>Queue: Check user queue size + Queue-->>LinkDisp: Under limit? + LinkDisp->>Queue: Acquire slot + LinkDisp->>User: Set reaction (👀) + LinkDisp->>Handler: handle_instagram_link(url) + + Handler->>Client: get_media(url) + Client->>RapidAPI: GET /convert?url=... + Note over Client,RapidAPI: X-Rapidapi-Key header + RapidAPI-->>Client: JSON response + Client-->>Handler: InstagramMediaInfo + + alt Single Video + Handler->>Handler: asyncio.gather(download video, download thumbnail) + Handler->>User: Send video (or document in file mode) + else Carousel/Images + Handler->>Handler: asyncio.gather(download all images) + Handler->>ImgProc: detect_image_format() for each + alt Non-native format (HEIC) + Handler->>ImgProc: convert_image_to_png() via ProcessPoolExecutor + end + Handler->>Handler: Split into batches of 10 + Handler->>User: Send media groups + end + + Handler->>DB: Log download + LinkDisp->>Queue: Release slot + LinkDisp->>User: Clear reaction +``` + +### Inline Mode Flow (TikTok + Instagram) ```mermaid sequenceDiagram participant User participant Inline as get_inline.py participant Client as TikTokClient + participant IGClient as InstagramClient participant Storage as Storage Channel User->>Inline: Inline query with URL Inline->>Inline: Validate user exists + Inline->>Inline: Match TikTok or Instagram regex Inline-->>User: Return result with minimal loading indicator User->>Inline: Select result - Inline->>Client: video(url) with bypass queue - alt Video - Client-->>Inline: VideoInfo + alt TikTok URL + Inline->>Client: video(url) with bypass queue + alt Video + Client-->>Inline: VideoInfo + Inline->>Storage: Upload video + Storage-->>Inline: file_id + Inline->>User: Edit inline message with video + else Slideshow + Inline->>User: Show "not supported" error + end + else Instagram URL + Inline->>IGClient: send_instagram_inline_video() + IGClient-->>Inline: Video data Inline->>Storage: Upload video Storage-->>Inline: file_id Inline->>User: Edit inline message with video - else Slideshow - Inline->>User: Show "not supported" error end ``` @@ -540,10 +667,11 @@ sequenceDiagram - **Dataclasses** for structured data (VideoInfo, MusicInfo, ProxySession) ### Error Handling -- Custom exception hierarchy (`TikTokError` base) -- 3-part retry with proxy rotation (instant retry) -- Permanent errors (deleted, private) not retried -- Emoji reactions for status updates (👀 → 👨‍💻 → result) +- Custom exception hierarchies (`TikTokError`, `InstagramError`) +- Extensible error registry: `register_error_mapping()` for any source +- TikTok: 3-part retry with proxy rotation (instant retry) +- Instagram: single-shot via RapidAPI (no retry) +- Emoji reactions for status updates (👀 → result or 😢) - Localized error messages (private chats only, groups fail silently) ### Resource Management @@ -585,10 +713,18 @@ sequenceDiagram - **Initialization order**: Must call `initialize_database_components()` before any DB ops - **URL rewriting**: PostgreSQL URLs auto-converted to `postgresql+asyncpg://` +### Instagram API +- **RapidAPI dependency**: All Instagram downloads go through a third-party RapidAPI endpoint — subject to rate limits and availability +- **No retry**: Unlike TikTok, Instagram requests are not retried on failure +- **Empty RAPIDAPI_KEY**: If env var is unset, all Instagram API calls will fail with 401/403 +- **URL regex coverage**: Matches `/p/`, `/reels/`, `/reel/`, `/tv/`, `/stories/` paths +- **Inline video only**: Instagram inline mode only supports single videos (no carousels) + ### Inline Mode - **Storage channel required**: Must set `STORAGE_CHANNEL_ID` -- **No slideshows**: Inline mode only supports videos +- **No slideshows**: Inline mode only supports videos (both TikTok and Instagram) - **Minimal loading indicator**: Button with "⏳" text and "loading" callback_data +- **Instagram prefix**: Instagram inline results use `"ig_download/"` prefix to distinguish from TikTok ## Navigation Guide @@ -606,8 +742,11 @@ sequenceDiagram | Add stats graph | `stats/router.py` + `stats/graphs.py` | | Modify video sending | `media_types/send_video.py:send_video_result()` | | Modify slideshow sending | `media_types/send_images.py:send_image_result()` | +| Modify Instagram extraction | `instagram_api/client.py` | +| Modify Instagram sending | `handlers/instagram.py` | +| Add new URL source | Create `source_api/` module → register errors in `__init__.py` → add filter in `handlers/link_dispatcher.py` → create handler | | Add unsupported content handler | `handlers/get_video.py` | -| Add new video source | Create source API module, call `register_error_mapping()` in `media_types/errors.py`, reuse `send_video_result`/`send_music_result` | +| Add new video source | Create source API module, call `register_error_mapping()` in `__init__.py`, add URL filter in `link_dispatcher.py`, create handler | ## Environment Variables @@ -619,6 +758,7 @@ sequenceDiagram | `TG_SERVER` | Telegram API server URL | | `TELEGRAM_API_ID` | Telegram API ID (for custom Bot API server) | | `TELEGRAM_API_HASH` | Telegram API hash (for custom Bot API server) | +| `RAPIDAPI_KEY` | RapidAPI key for Instagram downloader endpoint | ### Retry Configuration | Variable | Default | Description | @@ -641,10 +781,11 @@ See `.env.example` for complete list. ## Recent Changes (Since 2025-01-15) -1. **media_types/ package split** - Monolithic `misc/video_types.py` (861 lines) split into 8 focused modules with extensible error mapping for future sources -2. **Chrome 120 Impersonation** - Fixed to Chrome 120 to bypass TikTok WAF blocking -3. **Unsupported Content Handlers** - New handlers for videos, images, voice messages in private chats -4. **Minimal Loading Indicator** - Inline queries show "⏳" button instead of "Please wait" text -5. **Performance Hardcoding** - Removed performance config, hardcoded maximum throughput values -6. **Proxy Rotation Fixes** - Improved proxy handling for TikTok extraction -7. **Separate stats Docker image** - Stats bot runs in its own container +1. **Instagram download support** - New `instagram_api/` module with RapidAPI client, `handlers/instagram.py` for video/carousel downloads, `handlers/link_dispatcher.py` for URL routing, inline mode Instagram support +2. **media_types/ package split** - Monolithic `misc/video_types.py` split into 8 focused modules with extensible error mapping via `register_error_mapping()` +3. **Chrome 120 Impersonation** - Fixed to Chrome 120 to bypass TikTok WAF blocking +4. **Unsupported Content Handlers** - New handlers for videos, images, voice messages in private chats +5. **Minimal Loading Indicator** - Inline queries show "⏳" button instead of "Please wait" text +6. **Performance Hardcoding** - Removed performance config, hardcoded maximum throughput values +7. **Proxy Rotation Fixes** - Improved proxy handling for TikTok extraction +8. **Separate stats Docker image** - Stats bot runs in its own container From d321aeaded474adbc3dd55cc457445b704932af5 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Fri, 27 Feb 2026 23:15:13 -0800 Subject: [PATCH 2/4] Refactor inline query result IDs for consistency in download handling --- handlers/get_inline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/handlers/get_inline.py b/handlers/get_inline.py index bcd8424..7820a1b 100644 --- a/handlers/get_inline.py +++ b/handlers/get_inline.py @@ -54,7 +54,7 @@ async def handle_inline_query(inline_query: InlineQuery): if video_link is not None: results.append( InlineQueryResultArticle( - id=f"download/{query_text}", + id="tt_download", title=locale[lang]["inline_download_video"], description=locale[lang]["inline_download_video_description"], input_message_content=InputTextMessageContent( @@ -67,7 +67,7 @@ async def handle_inline_query(inline_query: InlineQuery): elif INSTAGRAM_URL_REGEX.search(query_text): results.append( InlineQueryResultArticle( - id=f"ig_download/{query_text}", + id="ig_download", title=locale[lang]["inline_download_instagram"], description=locale[lang]["inline_download_instagram_description"], input_message_content=InputTextMessageContent( @@ -107,7 +107,7 @@ async def handle_chosen_inline_result(chosen_result: ChosenInlineResult): return lang, file_mode = settings - is_instagram = chosen_result.result_id.startswith("ig_download/") + is_instagram = chosen_result.result_id == "ig_download" if is_instagram: await _handle_instagram_inline( From 7d3df9b437a960329b1d9347d438ae619288622f Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Wed, 4 Mar 2026 05:35:20 -0800 Subject: [PATCH 3/4] Add TikTok likes/views stats buttons to videos and slideshows Extract engagement metrics (diggCount, playCount) from the TikTok API response and display them as inline keyboard buttons above the existing music button in regular mode, and as standalone stats rows in inline mode including slideshow navigation. --- handlers/get_inline.py | 5 ++- handlers/get_music.py | 6 +++ handlers/inline_slideshow.py | 80 +++++++++++++++++++++++++----------- media_types/send_images.py | 2 +- media_types/send_video.py | 12 ++++-- media_types/ui.py | 52 ++++++++++++++++++++++- tiktok_api/client.py | 12 ++++++ tiktok_api/models.py | 2 + 8 files changed, 139 insertions(+), 32 deletions(-) diff --git a/handlers/get_inline.py b/handlers/get_inline.py index 46b62ca..7846864 100644 --- a/handlers/get_inline.py +++ b/handlers/get_inline.py @@ -24,7 +24,7 @@ from media_types import send_video_result, get_error_message from media_types.image_processing import ensure_native_format from media_types.storage import upload_photo_to_storage -from media_types.ui import result_caption +from media_types.ui import result_caption, stats_keyboard from handlers.inline_slideshow import register_slideshow inline_router = Router(name=__name__) @@ -175,12 +175,13 @@ async def _handle_tiktok_inline( message_id, image_urls, image_data, lang, video_link, user_id, username, full_name, client=api, video_info=video_info, + likes=video_info.likes, views=video_info.views, ) else: file_id = await upload_photo_to_storage( image_data, video_link, user_id, username, full_name ) - keyboard = None + keyboard = stats_keyboard(video_info.likes, video_info.views) if not file_id: raise ValueError( "Failed to upload photo to storage. " diff --git a/handlers/get_music.py b/handlers/get_music.py index c7be366..54a416a 100644 --- a/handlers/get_music.py +++ b/handlers/get_music.py @@ -10,6 +10,7 @@ from tiktok_api import TikTokClient, TikTokError, ProxyManager from misc.utils import lang_func, error_catch from media_types import send_music_result, music_button, get_error_message +from media_types.ui import STATS_CALLBACK_PREFIX music_router = Router(name=__name__) @@ -17,6 +18,11 @@ RETRY_EMOJIS = ["👀", "🤔", "🙏"] +@music_router.callback_query(F.data == STATS_CALLBACK_PREFIX) +async def handle_stats_noop(callback: CallbackQuery): + await callback.answer() + + @music_router.callback_query(F.data.startswith("id")) async def send_tiktok_sound(callback_query: CallbackQuery): # Vars diff --git a/handlers/inline_slideshow.py b/handlers/inline_slideshow.py index 750a520..eef017e 100644 --- a/handlers/inline_slideshow.py +++ b/handlers/inline_slideshow.py @@ -22,7 +22,7 @@ _build_storage_caption, upload_photo_to_storage, ) -from media_types.ui import result_caption +from media_types.ui import result_caption, stats_row from misc.utils import lang_func from tiktok_api import TikTokClient, ProxyManager from tiktok_api.models import VideoInfo @@ -44,6 +44,8 @@ class SlideshowSession: user_id: int username: str | None full_name: str | None + likes: int | None = None + views: int | None = None _cleanup_task: asyncio.Task | None = field(default=None, repr=False) _loading_indices: set[int] = field(default_factory=set, repr=False) @@ -52,18 +54,28 @@ class SlideshowSession: _refreshing_sessions: set[str] = set() # inline_message_ids currently refreshing -def _build_keyboard(index: int, total: int) -> InlineKeyboardMarkup: - buttons: list[InlineKeyboardButton] = [] +def _build_keyboard( + index: int, + total: int, + likes: int | None = None, + views: int | None = None, +) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + sr = stats_row(likes, views) + if sr: + rows.append(sr) + nav: list[InlineKeyboardButton] = [] if index > 0: - buttons.append(InlineKeyboardButton(text="◀️", callback_data="slide:prev")) - buttons.append( + nav.append(InlineKeyboardButton(text="◀️", callback_data="slide:prev")) + nav.append( InlineKeyboardButton( text=f"📸 {index + 1}/{total}", callback_data="slide:noop" ) ) if index < total - 1: - buttons.append(InlineKeyboardButton(text="▶️", callback_data="slide:next")) - return InlineKeyboardMarkup(inline_keyboard=[buttons]) + nav.append(InlineKeyboardButton(text="▶️", callback_data="slide:next")) + rows.append(nav) + return InlineKeyboardMarkup(inline_keyboard=rows) def _compress_url(source_link: str) -> str: @@ -88,24 +100,31 @@ def _expand_url(compressed: str) -> str: def _build_expired_keyboard( - index: int, total: int, source_link: str + index: int, + total: int, + source_link: str, + likes: int | None = None, + views: int | None = None, ) -> InlineKeyboardMarkup: """Build a keyboard with counter + refresh button for expired sessions.""" compressed = _compress_url(source_link) - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=f"📸 {index + 1}/{total}", - callback_data="slide:noop", - ), - InlineKeyboardButton( - text="🔄", - callback_data=f"sr:{index}:{compressed}", - ), - ] + rows: list[list[InlineKeyboardButton]] = [] + sr = stats_row(likes, views) + if sr: + rows.append(sr) + rows.append( + [ + InlineKeyboardButton( + text=f"📸 {index + 1}/{total}", + callback_data="slide:noop", + ), + InlineKeyboardButton( + text="🔄", + callback_data=f"sr:{index}:{compressed}", + ), ] ) + return InlineKeyboardMarkup(inline_keyboard=rows) async def _expire_session(inline_message_id: str) -> None: @@ -119,6 +138,8 @@ async def _expire_session(inline_message_id: str) -> None: session.current_index, len(session.image_urls), session.source_link, + session.likes, + session.views, ) await bot.edit_message_reply_markup( inline_message_id=inline_message_id, reply_markup=keyboard @@ -244,6 +265,8 @@ async def register_slideshow( full_name: str | None, client: TikTokClient | None = None, video_info: VideoInfo | None = None, + likes: int | None = None, + views: int | None = None, ) -> tuple[str, InlineKeyboardMarkup]: """Download all images, upload as galleries, create session, return (first_file_id, keyboard).""" file_ids = await _download_and_upload_images( @@ -268,10 +291,12 @@ async def register_slideshow( user_id=user_id, username=username, full_name=full_name, + likes=likes, + views=views, ) _slideshow_sessions[inline_message_id] = session _reset_ttl(inline_message_id, session) - return first_file_id, _build_keyboard(0, len(image_urls)) + return first_file_id, _build_keyboard(0, len(image_urls), likes, views) def cleanup_all_slideshows() -> None: @@ -342,7 +367,7 @@ async def handle_slideshow_callback(callback: CallbackQuery) -> None: session.current_index = new_index caption = result_caption(session.lang, session.source_link) media = InputMediaPhoto(media=file_id, caption=caption) - keyboard = _build_keyboard(new_index, total) + keyboard = _build_keyboard(new_index, total, session.likes, session.views) await bot.edit_message_media( inline_message_id=inline_message_id, @@ -438,6 +463,13 @@ async def handle_slideshow_refresh(callback: CallbackQuery) -> None: await callback.answer("Failed to upload images.", show_alert=True) return + # Extract stats from refreshed TikTok data if available + refresh_likes = None + refresh_views = None + if tiktok_video_info: + refresh_likes = tiktok_video_info.likes + refresh_views = tiktok_video_info.views + # Create new session session = SlideshowSession( image_urls=image_urls, @@ -448,13 +480,15 @@ async def handle_slideshow_refresh(callback: CallbackQuery) -> None: user_id=user_id, username=username, full_name=full_name, + likes=refresh_likes, + views=refresh_views, ) _slideshow_sessions[inline_message_id] = session # Edit message with refreshed image + nav keyboard caption = result_caption(lang, source_link) media = InputMediaPhoto(media=file_id, caption=caption) - keyboard = _build_keyboard(index, total) + keyboard = _build_keyboard(index, total, refresh_likes, refresh_views) await bot.edit_message_media( inline_message_id=inline_message_id, diff --git a/media_types/send_images.py b/media_types/send_images.py index 1133279..8ba4808 100644 --- a/media_types/send_images.py +++ b/media_types/send_images.py @@ -204,7 +204,7 @@ async def process_and_send_images(): if final and len(final) > 0: await final[0].reply( result_caption(lang, video_info.link, bool(image_limit)), - reply_markup=music_button(video_id, lang), + reply_markup=music_button(video_id, lang, video_info.likes, video_info.views), disable_web_page_preview=True, ) diff --git a/media_types/send_video.py b/media_types/send_video.py index 3e79bbc..8473232 100644 --- a/media_types/send_video.py +++ b/media_types/send_video.py @@ -5,7 +5,7 @@ from .http_session import download_thumbnail from .storage import upload_video_to_storage -from .ui import music_button, result_caption +from .ui import music_button, result_caption, stats_keyboard async def send_video_result( @@ -55,7 +55,11 @@ async def send_video_result( duration=video_duration, supports_streaming=True, ) - await bot.edit_message_media(inline_message_id=targed_id, media=video_media) + await bot.edit_message_media( + inline_message_id=targed_id, + media=video_media, + reply_markup=stats_keyboard(video_info.likes, video_info.views), + ) return if isinstance(video_data, bytes): @@ -68,7 +72,7 @@ async def send_video_result( chat_id=targed_id, document=video_file, caption=result_caption(lang, video_info.link), - reply_markup=music_button(video_id, lang), + reply_markup=music_button(video_id, lang, video_info.likes, video_info.views), reply_to_message_id=reply_to_message_id, disable_content_type_detection=True, ) @@ -86,6 +90,6 @@ async def send_video_result( duration=video_duration, thumbnail=thumbnail, supports_streaming=True, - reply_markup=music_button(video_id, lang), + reply_markup=music_button(video_id, lang, video_info.likes, video_info.views), reply_to_message_id=reply_to_message_id, ) diff --git a/media_types/ui.py b/media_types/ui.py index 3551702..de43d0a 100644 --- a/media_types/ui.py +++ b/media_types/ui.py @@ -1,12 +1,60 @@ -from aiogram.types import InlineKeyboardMarkup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder from data.config import locale +STATS_CALLBACK_PREFIX = "stats_noop" -def music_button(video_id: int, lang: str) -> InlineKeyboardMarkup: + +def format_stat(value: int) -> str: + if value >= 999_950: + formatted = f"{value / 1_000_000:.1f}".rstrip("0").rstrip(".") + return f"{formatted}M" + if value >= 1_000: + formatted = f"{value / 1_000:.1f}".rstrip("0").rstrip(".") + return f"{formatted}K" + return str(value) + + +def stats_row( + likes: int | None = None, views: int | None = None +) -> list[InlineKeyboardButton]: + buttons: list[InlineKeyboardButton] = [] + if likes is not None: + buttons.append( + InlineKeyboardButton( + text=f"❤️ {format_stat(likes)}", callback_data=STATS_CALLBACK_PREFIX + ) + ) + if views is not None: + buttons.append( + InlineKeyboardButton( + text=f"👁 {format_stat(views)}", callback_data=STATS_CALLBACK_PREFIX + ) + ) + return buttons + + +def stats_keyboard( + likes: int | None = None, views: int | None = None +) -> InlineKeyboardMarkup | None: + row = stats_row(likes, views) + return InlineKeyboardMarkup(inline_keyboard=[row]) if row else None + + +def music_button( + video_id: int, + lang: str, + likes: int | None = None, + views: int | None = None, +) -> InlineKeyboardMarkup: keyb = InlineKeyboardBuilder() + row = stats_row(likes, views) + for btn in row: + keyb.add(btn) keyb.button(text=locale[lang]["get_sound"], callback_data=f"id/{video_id}") + if row: + keyb.adjust(len(row), 1) return keyb.as_markup() diff --git a/tiktok_api/client.py b/tiktok_api/client.py index 8d292d3..7085b85 100644 --- a/tiktok_api/client.py +++ b/tiktok_api/client.py @@ -1521,6 +1521,10 @@ async def video(self, video_link: str) -> VideoInfo: if image_urls: author = video_data.get("author", {}).get("uniqueId", "") + stats = video_data.get("stats", {}) + likes = stats.get("diggCount") + views = stats.get("playCount") + # Transfer context ownership to VideoInfo # Part 3 (image download) happens later via download_slideshow_images() context_transferred = True @@ -1534,6 +1538,8 @@ async def video(self, video_link: str) -> VideoInfo: duration=None, link=video_link, url=None, + likes=likes, + views=views, _download_context=download_context, _proxy_session=proxy_session, # For Part 3 retry ) @@ -1586,6 +1592,10 @@ async def video(self, video_link: str) -> VideoInfo: height = video_info_data.get("height") cover = video_info_data.get("cover") or video_info_data.get("originCover") + stats = video_data.get("stats", {}) + likes = stats.get("diggCount") + views = stats.get("playCount") + return VideoInfo( type="video", data=video_bytes, @@ -1596,6 +1606,8 @@ async def video(self, video_link: str) -> VideoInfo: duration=duration, link=video_link, url=video_url, + likes=likes, + views=views, ) except TikTokError: diff --git a/tiktok_api/models.py b/tiktok_api/models.py index 6d2b155..9aa73ef 100644 --- a/tiktok_api/models.py +++ b/tiktok_api/models.py @@ -37,6 +37,8 @@ class VideoInfo: duration: Optional[int] link: str url: Optional[str] = None # Only present for videos + likes: Optional[int] = None + views: Optional[int] = None # Download context for slideshows (set by TikTokClient). # Contains yt-dlp YoutubeDL instance and TikTok extractor with cookies/auth From 05569f506af3ff7e3cd39ad85905289f23f6dbaa Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Wed, 4 Mar 2026 05:38:06 -0800 Subject: [PATCH 4/4] Add music note emoji to get_sound button text across all locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepend 🎵 emoji to the "Get Sound" button label in all 8 locale files for visual consistency with the new stats buttons. --- data/locale/ar.json | 2 +- data/locale/en.json | 2 +- data/locale/hi.json | 2 +- data/locale/id.json | 2 +- data/locale/ru.json | 2 +- data/locale/so.json | 2 +- data/locale/uk.json | 2 +- data/locale/vi.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/data/locale/ar.json b/data/locale/ar.json index 915abed..a69e626 100644 --- a/data/locale/ar.json +++ b/data/locale/ar.json @@ -9,7 +9,7 @@ "lang": "تم تعيين اللغة إلى العربية🇸🇦", "start": "لقد قمت بتشغيل No Watermark TikTok🤖\n\nيدعم هذا البوت تنزيل:\n📹الفيديو، 🖼الصور و 🔈الصوت\nمن TikTok وInstagram بدون علامة مائية\n\nيمكنك أيضًا الاشتراك في قناتنا للحصول على آخر الأخبار حول حالة البوت والتحديثات والإعلانات!\n@ttgrab\n\nأرسل رابط الفيديو للبدء", "maintenance": "⚠️ صيانة البوت ⚠️\n\nالبوت حاليًا تحت الصيانة وسيعود قريبًا.\nشكرًا لصبرك!\n\nتابع @ttgrab للحصول على التحديثات.", - "get_sound": "تحميل الصوت", + "get_sound": " \uD83C\uDFB5 تحميل الصوت", "bot_tag": "No Watermark TikTok", "result": "المصدر\n\n{0}", "result_song": "غلاف الأغنية\n\n{0}", diff --git a/data/locale/en.json b/data/locale/en.json index 3fddb73..037f4bf 100644 --- a/data/locale/en.json +++ b/data/locale/en.json @@ -9,7 +9,7 @@ "lang": "The language is set to English🇺🇸", "start": "You have launched No Watermark TikTok🤖\n\nThis bot supports download of:\n📹Video, 🖼Images and 🔈Audio\nfrom TikTok and Instagram without watermark\n\nYou can also subscribe to our channel to get the latest news about bot status, updates and news!\n@ttgrab\n\nSend video link to get started", "maintenance": "⚠️ Bot Maintenance ⚠️\n\nThe bot is currently undergoing maintenance and will be back soon.\nThank you for your patience!\n\nFollow @ttgrab for updates.", - "get_sound": "Get Sound", + "get_sound": "\uD83C\uDFB5 Get Sound", "bot_tag": "No Watermark TikTok", "result": "Source\n\n{0}", "result_song": "Song cover\n\n{0}", diff --git a/data/locale/hi.json b/data/locale/hi.json index 3a866db..329e992 100644 --- a/data/locale/hi.json +++ b/data/locale/hi.json @@ -9,7 +9,7 @@ "lang": "भाषा हिन्दी🇮🇳 पर सेट है", "start": "आपने No Watermark TikTok🤖 शुरू किया है\n\nयह बॉट TikTok और Instagram से बिना वॉटरमार्क:\n📹वीडियो, 🖼छवियाँ और 🔈ऑडियो\nडाउनलोड करने का समर्थन करता है\n\nआप बॉट की स्थिति, अपडेट और खबरों के लिए हमारे चैनल को भी सब्सक्राइब कर सकते हैं!\n@ttgrab\n\nशुरू करने के लिए वीडियो लिंक भेजें", "maintenance": "⚠️ बॉट मेंटेनेंस ⚠️\n\nबॉट अभी मेंटेनेंस में है और जल्द ही वापस आएगा।\nआपके धैर्य के लिए धन्यवाद!\n\nअपडेट के लिए @ttgrab फॉलो करें।", - "get_sound": "साउंड प्राप्त करें", + "get_sound": "\uD83C\uDFB5 साउंड प्राप्त करें", "bot_tag": "No Watermark TikTok", "result": "स्रोत\n\n{0}", "result_song": "गाने का कवर\n\n{0}", diff --git a/data/locale/id.json b/data/locale/id.json index 0c99269..e1fe685 100644 --- a/data/locale/id.json +++ b/data/locale/id.json @@ -9,7 +9,7 @@ "lang": "Bahasa disetel ke Bahasa Indonesia🇮🇩", "start": "Kamu telah menjalankan No Watermark TikTok🤖\n\nBot ini mendukung unduhan:\n📹Video, 🖼Gambar, dan 🔈Audio\ndari TikTok dan Instagram tanpa watermark\n\nKamu juga bisa berlangganan channel kami untuk mendapatkan kabar terbaru tentang status bot, pembaruan, dan berita!\n@ttgrab\n\nKirim tautan video untuk memulai", "maintenance": "⚠️ Pemeliharaan Bot ⚠️\n\nBot sedang dalam pemeliharaan dan akan segera kembali.\nTerima kasih atas kesabaranmu!\n\nIkuti @ttgrab untuk pembaruan.", - "get_sound": "Unduh Audio", + "get_sound": "\uD83C\uDFB5 Unduh Audio", "bot_tag": "No Watermark TikTok", "result": "Sumber\n\n{0}", "result_song": "Sampul lagu\n\n{0}", diff --git a/data/locale/ru.json b/data/locale/ru.json index c64473d..9e18c1a 100644 --- a/data/locale/ru.json +++ b/data/locale/ru.json @@ -9,7 +9,7 @@ "lang": "Установлен язык Русский🇷🇺", "start": "Вы запустили No Watermark TikTok🤖\n\nЭтот бот поддерживает загрузку:\n📹Видео, 🖼Изображений и 🔈Аудио\nс TikTok и Instagram без водяного знака\n\nВы также можете подписаться на наш канал, чтобы получать последние новости о статусе бота, обновлениях и новостях!\n@ttgrab\n\nОтправьте ссылку на видео, чтобы начать", "maintenance": "⚠️ Техническое обслуживание бота ⚠️\n\nВ настоящее время бот находится на техническом обслуживании и скоро вернется.\nСпасибо за ваше терпение!\n\nСледите за обновлениями в @ttgrab.", - "get_sound": "Скачать звук", + "get_sound": "\uD83C\uDFB5 Скачать звук", "bot_tag": "No Watermark TikTok", "result": "Оригинал\n\n{0}", "result_song": "Обложка\n\n{0}", diff --git a/data/locale/so.json b/data/locale/so.json index c9af713..9882f82 100644 --- a/data/locale/so.json +++ b/data/locale/so.json @@ -9,7 +9,7 @@ "lang": "Luqadda waxaa loo dejiyey Soomaali🇸🇴", "start": "Waxaad bilowday No Watermark TikTok🤖\n\nBot-kan wuxuu taageeraa soo dejinta:\n📹Fiidiyow, 🖼Sawirro iyo 🔈Cod\nTikTok iyo Instagram aan watermark lahayn\n\nWaxaad sidoo kale raaci kartaa kanaalkeenna si aad u hesho wararkii ugu dambeeyay ee xaaladda bot-ka, cusboonaysiinta iyo wararka!\n@ttgrab\n\nDir link-ga fiidiyowga si aad u bilowdo", "maintenance": "⚠️ Dayactirka Bot-ka ⚠️\n\nBot-ku hadda wuxuu ku jiraa dayactir wuxuuna soo laaban doonaa dhawaan.\nWaad ku mahadsan tahay dulqaadkaaga!\n\nRaac @ttgrab si aad u hesho warar cusub.", - "get_sound": "Hel Cod", + "get_sound": "\uD83C\uDFB5 Hel Cod", "bot_tag": "No Watermark TikTok", "result": "Isha\n\n{0}", "result_song": "Daboolka heesta\n\n{0}", diff --git a/data/locale/uk.json b/data/locale/uk.json index cceff59..fff5b78 100644 --- a/data/locale/uk.json +++ b/data/locale/uk.json @@ -9,7 +9,7 @@ "lang": "Встановлено мову Українська🇺🇦", "start": "Ви запустили No Watermark TikTok🤖\n\nЦей бот підтримує завантаження:\n📹Відео, 🖼Зображень та 🔈Аудіо\nз TikTok та Instagram без водяного знака\n\nВи також можете підписатися на наш канал, щоб отримувати останні новини про статус бота, оновлення та новини!\n@ttgrab\n\nНадішліть посилання на відео, щоб почати", "maintenance": "⚠️ Технічне обслуговування бота ⚠️\n\nНаразі бот перебуває на технічному обслуговуванні і скоро повернеться.\nДякуємо за ваше терпіння!\n\nСлідкуйте за оновленнями в @ttgrab.", - "get_sound": "Завантажити звук", + "get_sound": "\uD83C\uDFB5 Завантажити звук", "bot_tag": "No Watermark TikTok", "result": "Оригінал\n\n{0}", "result_song": "Обкладинка\n\n{0}", diff --git a/data/locale/vi.json b/data/locale/vi.json index 7325922..1b11113 100644 --- a/data/locale/vi.json +++ b/data/locale/vi.json @@ -9,7 +9,7 @@ "lang": "Ngôn ngữ hiện tại: Tiếng Việt🇻🇳", "start": "Bạn đã khởi chạy No Watermark TikTok🤖\n\nBot này hỗ trợ tải:\n📹Video, 🖼Hình ảnh và 🔈Âm thanh\ntừ TikTok và Instagram không có watermark\n\nBạn cũng có thể theo dõi kênh của chúng tôi để nhận tin mới nhất về trạng thái bot, cập nhật và thông báo!\n@ttgrab\n\nGửi liên kết video để bắt đầu", "maintenance": "⚠️ Bảo trì bot ⚠️\n\nBot hiện đang được bảo trì và sẽ sớm quay lại.\nCảm ơn bạn đã kiên nhẫn!\n\nTheo dõi @ttgrab để cập nhật.", - "get_sound": "Tải âm thanh", + "get_sound": "\uD83C\uDFB5 Tải âm thanh", "bot_tag": "No Watermark TikTok", "result": "Nguồn\n\n{0}", "result_song": "Bìa bài hát\n\n{0}",