package handler import ( "context" "fmt" "io" "log/slog" "math" "net/http" "os" "path/filepath" "regexp" "strings" "time" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "uptodownBot/internal/dl" "uptodownBot/internal/domain" ) type stepState struct { URL string MediaInfo *domain.MediaInfo Step int SelectedType string // "video", "audio", "ask", or "" when not yet chosen MainFormatID string MainFormat *domain.Format SecondaryID string SecondaryFmt *domain.Format Language string FormatIDs []string SearchState *domain.SearchState CreatedAt time.Time } const stepStateTTL = 30 * time.Minute func (h *Handler) getUserStepState(chatID int64) *stepState { h.stepStatesMu.Lock() defer h.stepStatesMu.Unlock() ss := h.stepStates[chatID] if ss != nil && time.Since(ss.CreatedAt) > stepStateTTL { delete(h.stepStates, chatID) return nil } return ss } func (h *Handler) setUserStepState(chatID int64, ss *stepState) { h.stepStatesMu.Lock() defer h.stepStatesMu.Unlock() h.stepStates[chatID] = ss } func (h *Handler) clearStepState(chatID int64) { h.stepStatesMu.Lock() defer h.stepStatesMu.Unlock() delete(h.stepStates, chatID) } // stripPlaylistParam removes the list parameter from YouTube/Music URLs. // Playlists are not supported; this converts a video-with-playlist URL (e.g. a // YouTube Music radio) to a plain video URL. Pure playlist URLs are rejected. func stripPlaylistParam(rawURL string) (string, bool) { // Reject pure playlist URLs (https://youtube.com/playlist?list=...) if strings.Contains(rawURL, "/playlist") || strings.Contains(rawURL, "youtube.com/pL") { return "", false } // Strip the list query parameter from watch URLs url := rawURL if strings.Contains(url, "list=") { // Remove &list=... or ?list=... url = regexp.MustCompile(`[?&]list=[^&]+`).ReplaceAllString(url, "") // Clean up trailing ? or & if the param was the only one url = strings.TrimRight(url, "?&") } return url, true } // handleURLInput is the entry point when a user sends a URL or types in Download mode. func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) { url, ok := domain.ValidateURL(text) if !ok { h.sendText(ctx, chatID, "Please send a valid URL (http/https).") return } // Strip playlist parameter and reject pure playlists. url, ok = stripPlaylistParam(url) if !ok { h.sendText(ctx, chatID, "Playlists are not supported. Send a single video/audio URL.") return } if job := h.getActiveJob(userID); job != nil { h.sendText(ctx, chatID, "You already have an active download. Cancel it first.") return } info, err := h.YtDlp.GetMediaInfo(url) if err != nil { errMsg := err.Error() // DRM content (Spotify, etc.): fall back to YouTube search if strings.Contains(errMsg, "DRM") { youtubeURL, ytTitle, searchErr := h.YtDlp.SearchYoutube(text) if searchErr != nil { kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Search on YouTube", CallbackData: "drm_search"}}, {{Text: "Cancel", CallbackData: "pending_cancel"}}, }, } h.sendWithKB(ctx, chatID, "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", kb) return } h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle)) info, err = h.YtDlp.GetMediaInfo(youtubeURL) if err != nil { h.sendText(ctx, chatID, fmt.Sprintf("YouTube fallback failed: %s", err.Error())) return } url = youtubeURL } else { switch { case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"): h.sendText(ctx, chatID, "This URL is not supported.") case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"): h.sendText(ctx, chatID, "This content requires authentication (cookies).") case strings.Contains(errMsg, "live"): h.sendText(ctx, chatID, "Cannot download live streams.") default: h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg)) } return } } if len(info.Formats) == 0 { h.sendText(ctx, chatID, "No formats available for this URL.") return } // Reject if user has already exhausted quota (limit of 0 = unlimited). q, qErr := h.Repo.GetOrCreateQuotas(userID) if qErr == nil { q = h.resetQuotasIfNeeded(q) if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) || (q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) || (q.MonthlyLimitMB > 0 && q.MonthlyUsedMB >= q.MonthlyLimitMB) { h.sendText(ctx, chatID, fmt.Sprintf( "Quota exceeded. Daily: %d/%d MB, Weekly: %d/%d MB, Monthly: %d/%d MB.", q.DailyUsedMB, q.DailyLimitMB, q.WeeklyUsedMB, q.WeeklyLimitMB, q.MonthlyUsedMB, q.MonthlyLimitMB)) return } } h.sendMediaPreview(ctx, chatID, info) prefs, prefErr := h.Repo.GetOrCreatePreferences(userID) if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk { // Media type is set — check quality preference var qualityPref string switch prefs.DefaultMediaType { case domain.MediaTypeVideo: qualityPref = prefs.DefaultVideoFormat case domain.MediaTypeAudio: qualityPref = prefs.DefaultAudioFormat } if qualityPref != "" { h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs) return } // Quality is Ask — show filtered format picker ss := &stepState{ URL: url, MediaInfo: info, Step: 0, CreatedAt: time.Now(), } h.setUserStepState(chatID, ss) h.showFormatsForType(ctx, chatID, userID, ss, prefs.DefaultMediaType.String()) return } ss := &stepState{ URL: url, MediaInfo: info, Step: 0, CreatedAt: time.Now(), } h.setUserStepState(chatID, ss) kb := mediaTypeKeyboard() h.sendWithKB(ctx, chatID, "Select media type:", kb) } // autoSelectAndDownload picks the best matching format from preferences and // skips the picker entirely. func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo, prefs *domain.UserPreferences) { msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: "Processing..."}) if err != nil { slog.Warn("send processing message failed", "error", err) return } msgID := msg.ID ss := &stepState{ URL: url, MediaInfo: info, Step: 0, CreatedAt: time.Now(), } var mainID, secondaryID string switch prefs.DefaultMediaType { case domain.MediaTypeVideo: mainID, secondaryID = autoSelectVideoFormat(info.Formats, prefs.DefaultVideoFormat) case domain.MediaTypeAudio: mainID = autoSelectAudioFormat(info.Formats, prefs.DefaultAudioFormat) default: mainID = "best" } if mainID == "" { mainID = "best" } ss.MainFormatID = mainID if mainID == "best" { if info.IsAudioOnlyContent() { ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true} } else { ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true} } } else { for i := range info.Formats { f := &info.Formats[i] if f.ID == mainID { ss.MainFormat = f break } } } if secondaryID != "" { ss.SecondaryID = secondaryID for i := range info.Formats { f := &info.Formats[i] if f.ID == secondaryID { ss.SecondaryFmt = f break } } } h.advanceToLanguage(ctx, chatID, msgID, userID, ss) } // showFormatsForType sends the format picker filtered by the given media type. // Called when the user has a type preference but quality is Ask. func (h *Handler) showFormatsForType(ctx context.Context, chatID int64, userID int64, ss *stepState, mediaType string) { ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType) if err != nil { h.sendText(ctx, chatID, "No formats available.") return } ss.FormatIDs = ids h.setUserStepState(chatID, ss) labels := make([]string, len(ids)) for i, id := range ids { if id == "best" { labels[i] = "Best" } else { f := findFormatByID(ss.MediaInfo.Formats, id) if f != nil { labels[i] = formatLabelForDisplay(*f) } else { labels[i] = id } } } kb := formatKeyboard("format_", labels, ids) h.sendWithKB(ctx, chatID, "Select format:", kb) } // handleTypeSelection processes the initial type picker (Video/Audio/Ask). func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, mediaType string) { ss := h.getUserStepState(chatID) if ss == nil { slog.Warn("nil stepState in handleTypeSelection", "chat_id", chatID, "user_id", userID) return } ss.SelectedType = mediaType ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType) if err != nil { h.editText(ctx, chatID, msgID, "No formats available for this type.", nil) return } ss.FormatIDs = ids h.setUserStepState(chatID, ss) labels := make([]string, len(ids)) for i, id := range ids { if id == "best" { labels[i] = "Best" } else { f := findFormatByID(ss.MediaInfo.Formats, id) if f != nil { labels[i] = formatLabelForDisplay(*f) } else { labels[i] = id } } } kb := formatKeyboard("format_", labels, ids) h.editText(ctx, chatID, msgID, "Select format:", &kb) } // handleFormatSelection processes a format pick from the unified list. func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) { ss := h.getUserStepState(chatID) if ss == nil { slog.Warn("nil stepState in handleFormatSelection", "chat_id", chatID, "user_id", userID) return } ss.MainFormatID = formatID if formatID == "best" { if ss.MediaInfo.IsAudioOnlyContent() { ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true} } else { ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true} } h.advanceToLanguage(ctx, chatID, msgID, userID, ss) return } for i := range ss.MediaInfo.Formats { f := &ss.MediaInfo.Formats[i] if f.ID == formatID { ss.MainFormat = f break } } if ss.MainFormat == nil { return } // Video-only: always offer audio track if ss.MainFormat.HasVideo && !ss.MainFormat.HasAudio { h.showSecondaryFormats(ctx, chatID, msgID, userID, ss, "audio") return } // Audio-only: ask if they want to add video if !ss.MainFormat.HasVideo && ss.MainFormat.HasAudio { if ss.MediaInfo.IsAudioOnlyContent() { h.advanceToLanguage(ctx, chatID, msgID, userID, ss) return } text := "This format is audio-only. Add video?" kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Add Video", CallbackData: "secondary_video"}}, {{Text: "Audio Only", CallbackData: "secondary_skip"}}, {{Text: "Back", CallbackData: "back_step"}, {Text: "Cancel", CallbackData: "cancel_dl"}}, }, } h.editText(ctx, chatID, msgID, text, &kb) return } // Has both: proceed h.advanceToLanguage(ctx, chatID, msgID, userID, ss) } // showSecondaryFormats shows formats of the missing type to combine. func (h *Handler) showSecondaryFormats(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState, kind string) { var mt domain.MediaType if kind == "audio" { mt = domain.MediaTypeAudio } else { mt = domain.MediaTypeVideo } filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, mt) if len(filtered) == 0 { ss.SecondaryFmt = nil h.advanceToLanguage(ctx, chatID, msgID, userID, ss) return } deduped := dl.DeduplicateResolutions(filtered) ids := make([]string, len(deduped)) labels := make([]string, len(deduped)) for i, f := range deduped { ids[i] = f.ID labels[i] = formatLabelForDisplay(f) } ss.FormatIDs = ids h.setUserStepState(chatID, ss) prompt := fmt.Sprintf("Select %s format to combine:", kind) kb := formatKeyboard("secondary_", labels, ids) h.editText(ctx, chatID, msgID, prompt, &kb) } // handleSecondarySelection processes the secondary format selection. func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) { ss := h.getUserStepState(chatID) if ss == nil { slog.Warn("nil stepState in handleSecondarySelection", "chat_id", chatID, "user_id", userID) return } if formatID == "skip" || formatID == "video" || formatID == "audio" { // Handle the skip / add-video decisions from the audio-only prompt if formatID == "video" { h.showSecondaryFormats(ctx, chatID, msgID, userID, ss, "video") return } // skip — just proceed h.advanceToLanguage(ctx, chatID, msgID, userID, ss) return } ss.SecondaryID = formatID for i := range ss.MediaInfo.Formats { f := &ss.MediaInfo.Formats[i] if f.ID == formatID { ss.SecondaryFmt = f break } } h.advanceToLanguage(ctx, chatID, msgID, userID, ss) } // advanceToLanguage moves to language selection, or starts the download if only one language. func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) { ss.Step = 2 h.setUserStepState(chatID, ss) if len(ss.MediaInfo.Languages) > 1 { // Check if user has a default language and it's available. prefs, prefErr := h.Repo.GetOrCreatePreferences(userID) if prefErr == nil && prefs.DefaultLanguage != "" { for _, lang := range ss.MediaInfo.Languages { if lang == prefs.DefaultLanguage { ss.Language = lang h.startDownload(ctx, chatID, msgID, userID, ss) return } } } // No match or no preference — show picker. kb := languageKeyboard(ss.MediaInfo.Languages) h.editText(ctx, chatID, msgID, "Select language:", &kb) return } h.startDownload(ctx, chatID, msgID, userID, ss) } func (ss *stepState) determineMediaType() domain.MediaType { if ss.MainFormat == nil { return domain.MediaTypeVideoAudio } if ss.MainFormat.IsAudioOnly && ss.SecondaryFmt == nil { return domain.MediaTypeAudio } return domain.MediaTypeVideoAudio } func (ss *stepState) buildFormatString() string { if ss.MainFormatID == "best" { return "" } if ss.SecondaryFmt != nil { return ss.MainFormatID + "+" + ss.SecondaryID } return ss.MainFormatID } // handleLanguageSelection processes the language choice. func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) { ss := h.getUserStepState(chatID) if ss == nil { slog.Warn("nil stepState in handleLanguageSelection", "chat_id", chatID, "user_id", userID) return } ss.Language = language h.startDownload(ctx, chatID, msgID, userID, ss) } // startDownload creates the download job and begins the download process. func (h *Handler) startDownload(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) { mediaType := ss.determineMediaType() formatString := ss.buildFormatString() format := &domain.Format{ID: ss.MainFormatID} if ss.MainFormat != nil { format = ss.MainFormat } fileSize := format.Filesize if fileSize > 0 && fileSize > h.maxFileSize { h.editText(ctx, chatID, msgID, fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileSize), formatBytes(h.maxFileSize)), nil) return } dlCtx, dlCancel := context.WithCancel(context.Background()) job := &domain.DownloadJob{ ID: h.generateJobID(), UserID: userID, ChatID: chatID, MessageID: msgID, URL: ss.URL, MediaType: mediaType, SelectedFormat: format, FormatString: formatString, Language: ss.Language, Status: domain.StatusDownloading, Title: ss.MediaInfo.Title, FileSize: fileSize, MaxFileSize: h.maxFileSize, Done: make(chan struct{}), } h.editText(ctx, chatID, msgID, "Processing...", nil) h.setActiveJob(userID, job) h.setCancelFunc(userID, dlCancel) h.clearStepState(chatID) go h.runDownload(dlCtx, job) } // runDownload manages the download lifecycle, progress, and completion. func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) { defer func() { if job.Done != nil { close(job.Done) } }() select { case h.downloadSem <- struct{}{}: case <-ctx.Done(): return } defer func() { <-h.downloadSem }() kb := progressKeyboard(0, "", "") progressHeader := "Downloading: " + job.Title if job.ProgressPrefix != "" { progressHeader = job.ProgressPrefix + "\n" + progressHeader } msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: job.ChatID, Text: progressHeader, ReplyMarkup: &kb, }) if err != nil { slog.Error("send progress msg failed", "error", err) return } job.MessageID = msg.ID updates, err := h.YtDlp.StartDownload(job, h.downloadDir) if err != nil { errMsg := err.Error() if job.StderrLog != "" { if lines := strings.SplitN(job.StderrLog, "\n", 2); len(lines) > 0 && lines[0] != "" { errMsg = strings.TrimSpace(lines[0]) } } h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", errMsg), nil) job.Status = domain.StatusFailed h.clearActiveJob(job.UserID) return } h.trackDownloadProgress(ctx, job, updates) if job.Status == domain.StatusCompleted { h.handleDownloadCompletion(ctx, job) } h.clearActiveJob(job.UserID) } // trackDownloadProgress reads progress updates from yt-dlp and updates the Telegram message. func (h *Handler) trackDownloadProgress(ctx context.Context, job *domain.DownloadJob, updates <-chan *domain.DownloadJob) { lastProgress := 0.0 for { select { case upd, ok := <-updates: if !ok { return } slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status) h.activeJobsMu.Lock() currentJob := h.activeJobs[job.UserID] h.activeJobsMu.Unlock() if currentJob == nil { return } if upd.Status == domain.StatusCancelled { h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil) return } if upd.Status == domain.StatusFailed { h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil) return } if upd.Status == domain.StatusCompleted { return } if lastProgress == 0 { lastProgress = upd.Progress } else { diff := upd.Progress - lastProgress // Skip small changes (< threshold) in either direction to avoid spamming edits. // HLS streams often emit a fake 100% initial estimate then real values. if diff >= 0 && diff < domain.ProgressThreshold { continue } if diff < 0 && -diff < domain.ProgressThreshold { continue } lastProgress = upd.Progress } if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 { job.QuotaApplied = true h.applyQuota(job.UserID, job.FileSize) } kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA) text := "Downloading: " + job.Title if job.ProgressPrefix != "" { text = job.ProgressPrefix + "\n" + text } h.editText(ctx, job.ChatID, job.MessageID, text, &kb) case <-ctx.Done(): dl.CancelDownload(job) for range updates { } return } } } // handleDownloadCompletion sends the downloaded file and records history. // Uses context.Background() for Telegram notifications so that cancellation // of the download context does not prevent the file from being delivered. func (h *Handler) handleDownloadCompletion(ctx context.Context, job *domain.DownloadJob) { pattern := filepath.Join(h.downloadDir, job.ID+"_*") files, err := filepath.Glob(pattern) if err != nil || len(files) == 0 { h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed but file not found.", nil) return } f := files[0] fileInfo, err := os.Stat(f) if err != nil { h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed but file not found.", nil) return } if fileInfo.Size() > h.maxFileSize { h.editText(context.Background(), job.ChatID, job.MessageID, fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil) os.Remove(f) return } if !job.QuotaApplied && job.FileSize > 0 { job.QuotaApplied = true h.applyQuota(job.UserID, job.FileSize) } h.sendDocument(context.Background(), job.ChatID, f, job.Title) os.Remove(f) if err := h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{ URL: job.URL, Title: job.Title, MediaType: job.MediaType.String(), FormatID: job.SelectedFormat.ID, FileSize: job.FileSize, Status: "completed", }); err != nil { slog.Error("add history failed", "user_id", job.UserID, "error", err) } h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed!", nil) } func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, title string) { f, err := os.Open(filePath) if err != nil { slog.Error("open file for send", "path", filePath, "error", err) return } defer f.Close() doc := &models.InputFileUpload{ Filename: title + filepath.Ext(filePath), Data: f, } if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{ ChatID: chatID, Document: doc, }); err != nil { slog.Error("send document failed", "error", err) } } // handleCancelDownload cancels the current download for a user. func (h *Handler) handleCancelDownload(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) { defer h.answerCb(ctx, cbID) h.cancelFuncsMu.Lock() if cancel, ok := h.cancelFuncs[userID]; ok { cancel() delete(h.cancelFuncs, userID) } h.cancelFuncsMu.Unlock() job := h.getActiveJob(userID) if job != nil { dl.CancelDownload(job) h.activeJobsMu.Lock() delete(h.activeJobs, userID) h.activeJobsMu.Unlock() h.editText(ctx, chatID, msgID, "Download cancelled.", nil) return } h.clearStepState(chatID) h.editText(ctx, chatID, msgID, "Cancelled.", nil) } // handleBackStep goes back one step in the download flow. func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) { defer h.answerCb(ctx, cbID) ss := h.getUserStepState(chatID) if ss == nil { slog.Warn("nil stepState in handleBackStep", "chat_id", chatID, "user_id", userID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) return } // If on the format list with a selected type, go back to type picker. if ss.Step == 0 && ss.SelectedType != "" { ss.SelectedType = "" h.setUserStepState(chatID, ss) kb := mediaTypeKeyboard() h.editText(ctx, chatID, msgID, "Select media type:", &kb) return } ss.Step-- switch ss.Step { case -1: h.clearStepState(chatID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) case 0: ss.FormatIDs, _ = buildFilteredFormatList(ss.MediaInfo.Formats, "") labels := make([]string, len(ss.FormatIDs)) for i, id := range ss.FormatIDs { if id == "best" { labels[i] = "Best" } else { f := findFormatByID(ss.MediaInfo.Formats, id) if f != nil { labels[i] = formatLabelForDisplay(*f) } else { labels[i] = id } } } h.setUserStepState(chatID, ss) kb := formatKeyboard("format_", labels, ss.FormatIDs) h.editText(ctx, chatID, msgID, "Select format:", &kb) case 1: // Re-show secondary format list or main format with secondary info cleared ss.SecondaryFmt = nil ss.SecondaryID = "" if ss.MainFormat != nil && ss.MainFormat.HasVideo && !ss.MainFormat.HasAudio { h.showSecondaryFormats(ctx, chatID, msgID, userID, ss, "audio") } else { h.advanceToLanguage(ctx, chatID, msgID, userID, ss) } case 2: if len(ss.MediaInfo.Languages) > 1 { kb := languageKeyboard(ss.MediaInfo.Languages) h.editText(ctx, chatID, msgID, "Select language:", &kb) } else { ss.Step = 1 h.handleBackStep(ctx, chatID, msgID, cbID, userID) } } } // handleDownloadCommand handles the /download command, prompting for a URL. func (h *Handler) handleDownloadCommand(ctx context.Context, msg *models.Message, userID int64) { h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingURL, "Send me the URL to download:", nil) } // handleDownloadPrompt shows the download prompt (asks for URL). func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) { h.promptForInput(ctx, chatID, msgID, userID, PendingURL, "Send me the URL to download:", nil) } // buildFormatList returns a deduplicated format ID list with "best" first, then video, then audio. func buildFormatList(formats []domain.Format) ([]string, error) { deduped := dl.DeduplicateResolutions(formats) ids := make([]string, 0, len(deduped)+1) ids = append(ids, "best") for _, f := range deduped { if f.HasVideo { ids = append(ids, f.ID) } } for _, f := range deduped { if !f.HasVideo { ids = append(ids, f.ID) } } return ids, nil } // buildFilteredFormatList returns format IDs filtered by media type for the type picker. // When mediaType is "video", only formats with video are returned. // When mediaType is "audio", only audio-only formats are returned. // When empty or "ask", all formats are returned (current behavior). func buildFilteredFormatList(formats []domain.Format, mediaType string) ([]string, error) { deduped := dl.DeduplicateResolutions(formats) ids := make([]string, 0, len(deduped)+1) ids = append(ids, "best") switch mediaType { case "video": for _, f := range deduped { if f.HasVideo { ids = append(ids, f.ID) } } case "audio": for _, f := range deduped { if f.HasAudio && f.IsAudioOnly { ids = append(ids, f.ID) } } default: for _, f := range deduped { if f.HasVideo { ids = append(ids, f.ID) } } for _, f := range deduped { if !f.HasVideo { ids = append(ids, f.ID) } } } return ids, nil } // autoSelectVideoFormat finds the closest video format to the preferred resolution. // Returns the main format ID and optionally a secondary audio ID if the best match // is video-only. Prefers formats at or above the target resolution, falling back to // the closest below. Returns ("best", "") if the preference is "best" or no match. func autoSelectVideoFormat(formats []domain.Format, preferredID string) (mainID, secondaryID string) { if preferredID == "" || preferredID == "best" { return "best", "" } var targetHeight int if n, err := fmt.Sscanf(preferredID, "%dp", &targetHeight); err != nil || n != 1 { return "best", "" } var bestFmt *domain.Format bestDiff := math.MaxInt32 for i := range formats { f := &formats[i] if !f.HasVideo || f.Height == 0 { continue } diff := f.Height - targetHeight if diff >= 0 && diff < bestDiff { bestDiff = diff bestFmt = f } } if bestFmt == nil { bestDiff = math.MaxInt32 for i := range formats { f := &formats[i] if !f.HasVideo || f.Height == 0 { continue } diff := targetHeight - f.Height if diff >= 0 && diff < bestDiff { bestDiff = diff bestFmt = f } } } if bestFmt == nil { return "best", "" } mainID = bestFmt.ID if bestFmt.HasVideo && !bestFmt.HasAudio { for i := range formats { f := &formats[i] if f.HasAudio && f.IsAudioOnly { secondaryID = f.ID break } } } return } // autoSelectAudioFormat finds the closest audio format to the preferred bitrate. // Returns the format ID, or "best" if the preference is "best" or no match. func autoSelectAudioFormat(formats []domain.Format, preferredID string) string { if preferredID == "" || preferredID == "best" { return "best" } var targetBitrate int if n, err := fmt.Sscanf(preferredID, "%dk", &targetBitrate); err != nil || n != 1 { return "best" } var bestFmt *domain.Format bestDiff := math.MaxInt32 for i := range formats { f := &formats[i] if !f.HasAudio || !f.IsAudioOnly { continue } var bitrate int if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 { continue } diff := bitrate - targetBitrate if diff >= 0 && diff < bestDiff { bestDiff = diff bestFmt = f } } if bestFmt == nil { bestDiff = math.MaxInt32 for i := range formats { f := &formats[i] if !f.HasAudio || !f.IsAudioOnly { continue } var bitrate int if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 { continue } diff := targetBitrate - bitrate if diff >= 0 && diff < bestDiff { bestDiff = diff bestFmt = f } } } if bestFmt == nil { return "best" } return bestFmt.ID } // findFormatByID returns the format with the given ID from the list, or nil. func findFormatByID(formats []domain.Format, id string) *domain.Format { for i := range formats { if formats[i].ID == id { return &formats[i] } } return nil } func formatLabelForDisplay(f domain.Format) string { if f.IsAudioOnly { label := f.Bitrate if label == "" { label = f.ID if f.Extension != "" { label += " (" + f.Extension + ")" } } if f.Filesize > 0 { label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) } return label } if f.Resolution != "" { label := f.Resolution if f.Filesize > 0 { label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) } return label } if f.Height > 0 { label := fmt.Sprintf("%dp", f.Height) if f.Filesize > 0 { label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) } return label } if f.Bitrate != "" { label := f.Bitrate if f.Filesize > 0 { label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) } return label } label := f.ID if f.Extension != "" { label += " (" + f.Extension + ")" } return label } // sendMediaPreview sends a thumbnail and metadata info before the selection flow. func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) { caption := info.Title if info.Uploader != "" || info.Duration > 0 { caption += "\n" if info.Uploader != "" { caption += fmt.Sprintf("Channel: %s", info.Uploader) } if info.Duration > 0 { if info.Uploader != "" { caption += " | " } caption += fmt.Sprintf("Duration: %s", formatDuration(info.Duration)) } } if info.Thumbnail != "" { thumbPath, err := downloadFile(info.Thumbnail, h.downloadDir) if err != nil { slog.Warn("download thumbnail failed, falling back to text", "url", info.Thumbnail, "error", err) h.sendText(ctx, chatID, caption) return } defer os.Remove(thumbPath) f, err := os.Open(thumbPath) if err != nil { slog.Warn("open thumbnail failed, falling back to text", "error", err) h.sendText(ctx, chatID, caption) return } defer f.Close() photo := &models.InputFileUpload{Filename: "thumb.jpg", Data: f} _, err = h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{ ChatID: chatID, Photo: photo, Caption: caption, }) if err == nil { return } slog.Warn("send thumbnail photo failed, falling back to text", "error", err) } h.sendText(ctx, chatID, caption) } // downloadFile downloads a URL to a temp file in the given directory and returns the path. func downloadFile(url, dir string) (string, error) { tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, } client := &http.Client{ Timeout: 30 * time.Second, Transport: tr, } resp, err := client.Get(url) if err != nil { return "", fmt.Errorf("http get: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status: %s", resp.Status) } if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("create download dir: %w", err) } f, err := os.CreateTemp(dir, "thumb_*") if err != nil { return "", fmt.Errorf("create temp file: %w", err) } _, err = io.Copy(f, resp.Body) if err != nil { f.Close() os.Remove(f.Name()) return "", fmt.Errorf("write temp file: %w", err) } if err := f.Close(); err != nil { os.Remove(f.Name()) return "", fmt.Errorf("close temp file: %w", err) } return f.Name(), nil } func formatDuration(seconds float64) string { if seconds <= 0 { return "0:00" } d := time.Duration(seconds) * time.Second h := int(d.Hours()) m := int(math.Mod(d.Minutes(), 60)) s := int(math.Mod(d.Seconds(), 60)) if h > 0 { return fmt.Sprintf("%d:%02d:%02d", h, m, s) } return fmt.Sprintf("%d:%02d", m, s) }