feat: add in-memory media info cache and YouTube search feature

- Add in-memory LRU cache (200 entries, 30 min TTL) to yt-dlp Client for
  GetMediaInfo and GetPlaylistInfo results, avoiding redundant yt-dlp calls
  for the same URL within a session
- Add YouTube (/search) and YouTube Music (/music) search with paginated
  results (5 per page), user selects a result to start the download flow
- Add SearchState type, new search.go handler, search results keyboard with
  < > navigation and New Search button
- Add Search button to main menu keyboard
- Update help text with new commands
This commit is contained in:
2026-06-25 22:58:20 +03:30
parent 66a8cd1128
commit 3290bb55c0
6 changed files with 246 additions and 3 deletions

136
internal/handler/search.go Normal file
View File

@@ -0,0 +1,136 @@
package handler
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/domain"
)
func (h *Handler) handleSearchCommand(ctx context.Context, msg *models.Message, userID int64) {
h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingSearch,
"Enter a YouTube search query:", map[string]string{"source": "youtube"})
}
func (h *Handler) handleMusicSearchCommand(ctx context.Context, msg *models.Message, userID int64) {
h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingSearch,
"Enter a YouTube Music search query:", map[string]string{"source": "ytmusic"})
}
func (h *Handler) handleSearchQuery(ctx context.Context, chatID int64, text string, userID int64, source string) {
results, err := h.YtDlp.Search(text, source, 10)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("Search failed: %s", err.Error()))
return
}
if len(results) == 0 {
h.sendText(ctx, chatID, "No results found.")
return
}
state := &domain.SearchState{
Query: text,
Entries: results,
Page: 0,
PerPage: 5,
Source: source,
}
ss := &stepState{SearchState: state}
h.setUserStepState(chatID, ss)
kb := searchResultsKeyboard(state)
h.sendWithKB(ctx, chatID, fmt.Sprintf("Search results for '%s':", text), kb)
}
func (h *Handler) handleSearchCallback(ctx context.Context, chatID int64, msgID int, userID int64, data string) {
switch {
case data == "refresh":
ss := h.getUserStepState(chatID)
if ss == nil || ss.SearchState == nil {
return
}
h.handleSearchQuery(ctx, chatID, ss.SearchState.Query, userID, ss.SearchState.Source)
case strings.HasPrefix(data, "page_"):
h.handleSearchPage(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "select_"):
h.handleSearchSelect(ctx, chatID, msgID, userID, data[7:])
}
}
func (h *Handler) handleSearchPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
ss := h.getUserStepState(chatID)
if ss == nil || ss.SearchState == nil {
return
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return
}
totalPages := (len(ss.SearchState.Entries) + ss.SearchState.PerPage - 1) / ss.SearchState.PerPage
if page < 0 || page >= totalPages {
return
}
ss.SearchState.Page = page
h.setUserStepState(chatID, ss)
kb := searchResultsKeyboard(ss.SearchState)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Search results for '%s':", ss.SearchState.Query), &kb)
}
func (h *Handler) handleSearchSelect(ctx context.Context, chatID int64, msgID int, userID int64, entryURL string) {
h.clearStepState(chatID)
h.editText(ctx, chatID, msgID, "Fetching media info...", nil)
h.handleURLInput(ctx, chatID, entryURL, userID)
}
func searchResultsKeyboard(state *domain.SearchState) models.InlineKeyboardMarkup {
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if totalPages < 1 {
totalPages = 1
}
rows := [][]models.InlineKeyboardButton{}
start := state.Page * state.PerPage
end := start + state.PerPage
if end > len(state.Entries) {
end = len(state.Entries)
}
for i := start; i < end; i++ {
e := state.Entries[i]
title := e.Title
if len(title) > 40 {
title = title[:37] + "..."
}
label := title
if e.Duration > 0 {
label += fmt.Sprintf(" (%s)", formatDuration(e.Duration))
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "search_select_" + e.URL},
})
}
navRow := []models.InlineKeyboardButton{}
navRow = append(navRow, models.InlineKeyboardButton{
Text: "<", CallbackData: fmt.Sprintf("search_page_%d", state.Page-1),
})
navRow = append(navRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("Page %d/%d", state.Page+1, totalPages), CallbackData: "noop",
})
navRow = append(navRow, models.InlineKeyboardButton{
Text: ">", CallbackData: fmt.Sprintf("search_page_%d", state.Page+1),
})
rows = append(rows, navRow)
rows = append(rows, []models.InlineKeyboardButton{
{Text: "New Search", CallbackData: "search_refresh"},
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}