package handler import ( "context" "fmt" "log/slog" "math" "os" "path/filepath" "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 MainFormatID string MainFormat *domain.Format SecondaryID string SecondaryFmt *domain.Format Language string Container string FormatIDs []string PlaylistState *domain.PlaylistState } func (h *Handler) getUserStepState(chatID int64) *stepState { h.mu.Lock() defer h.mu.Unlock() return h.stepStates[chatID] } func (h *Handler) setUserStepState(chatID int64, ss *stepState) { h.mu.Lock() defer h.mu.Unlock() h.stepStates[chatID] = ss } func (h *Handler) clearStepState(chatID int64) { h.mu.Lock() defer h.mu.Unlock() delete(h.stepStates, chatID) } // 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 } 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.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: chatID, Text: "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", ReplyMarkup: 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 info.IsPlaylist { h.handlePlaylist(ctx, chatID, userID, url, info) return } if len(info.Formats) == 0 { h.sendText(ctx, chatID, "No formats available for this URL.") return } q, err := h.Repo.GetOrCreateQuotas(userID) if err == nil { q = h.resetQuotasIfNeeded(q) estimatedMB := info.EstimatedSize / (1024 * 1024) if estimatedMB > 0 { if q.DailyUsedMB+estimatedMB > q.DailyLimitMB || q.WeeklyUsedMB+estimatedMB > q.WeeklyLimitMB || q.MonthlyUsedMB+estimatedMB > 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 } } } ss := &stepState{ URL: url, MediaInfo: info, Step: 0, } h.sendMediaPreview(ctx, chatID, info) // Build unified format list: video formats first, then audio deduped := dl.DeduplicateResolutions(info.Formats) var videoFmts, audioFmts []domain.Format for _, f := range deduped { if f.HasVideo { videoFmts = append(videoFmts, f) } else { audioFmts = append(audioFmts, f) } } ss.FormatIDs = make([]string, 0, len(deduped)+1) labels := make([]string, 0, len(deduped)+1) ss.FormatIDs = append(ss.FormatIDs, "best") labels = append(labels, "Best") for _, f := range videoFmts { ss.FormatIDs = append(ss.FormatIDs, f.ID) labels = append(labels, formatLabelForDisplay(f)) } for _, f := range audioFmts { ss.FormatIDs = append(ss.FormatIDs, f.ID) labels = append(labels, formatLabelForDisplay(f)) } h.setUserStepState(chatID, ss) kb := formatKeyboard("format_", labels, ss.FormatIDs) h.sendWithKB(ctx, chatID, "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 { 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 { 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 skips to container if N/A. 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 { kb := languageKeyboard(ss.MediaInfo.Languages) h.editText(ctx, chatID, msgID, "Select language:", &kb) return } h.advanceToContainer(ctx, chatID, msgID, userID, ss) } // advanceToContainer shows the container picker. func (h *Handler) advanceToContainer(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) { ss.Step = 3 h.setUserStepState(chatID, ss) containers := dl.ContainerOptions(ss.determineMediaType()) kb := containerKeyboard(containers) h.editText(ctx, chatID, msgID, "Select container format:", &kb) } 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 { return } ss.Language = language h.advanceToContainer(ctx, chatID, msgID, userID, ss) } // handleContainerSelection processes the container choice and starts the download. func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, msgID int, userID int64, container string) { ss := h.getUserStepState(chatID) if ss == nil { return } ss.Container = container mediaType := ss.determineMediaType() formatString := ss.buildFormatString() format := &domain.Format{ID: ss.MainFormatID} if ss.MainFormat != nil { format = ss.MainFormat } job := &domain.DownloadJob{ ID: h.generateJobID(), UserID: userID, ChatID: chatID, MessageID: msgID, URL: ss.URL, MediaType: mediaType, SelectedFormat: format, FormatString: formatString, Container: container, Language: ss.Language, Status: domain.StatusDownloading, Title: ss.MediaInfo.Title, FileSize: format.Filesize, } h.setActiveJob(userID, job) h.clearStepState(chatID) go h.runDownload(context.Background(), job) } // runDownload manages the download lifecycle, progress, and completion. func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) { kb := progressKeyboard(0, "", "") msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: job.ChatID, Text: fmt.Sprintf("Downloading: %s", job.Title), 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 { h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", err.Error()), nil) job.Status = domain.StatusFailed h.mu.Lock() delete(h.activeJobs, job.UserID) h.mu.Unlock() return } lastProgress := 0.0 for upd := range updates { slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status) h.mu.Lock() currentJob := h.activeJobs[job.UserID] h.mu.Unlock() if currentJob == nil { return } if upd.Status == domain.StatusCancelled { h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil) h.mu.Lock() delete(h.activeJobs, job.UserID) h.mu.Unlock() return } if upd.Status == domain.StatusFailed { h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil) h.mu.Lock() delete(h.activeJobs, job.UserID) h.mu.Unlock() return } // Always show the first real progress update, then throttle at 5% intervals if upd.Progress > 0 && lastProgress == 0 { lastProgress = upd.Progress } else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted { continue } else { 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 := fmt.Sprintf("Downloading: %s", job.Title) h.editText(ctx, job.ChatID, job.MessageID, text, &kb) } h.mu.Lock() delete(h.activeJobs, job.UserID) h.mu.Unlock() if job.Status == domain.StatusCompleted { pattern := filepath.Join(h.downloadDir, job.ID+"_*") files, err := filepath.Glob(pattern) if err == nil && len(files) > 0 { f := files[0] fileInfo, err := os.Stat(f) if err == nil { if fileInfo.Size() > h.maxFileSize { h.editText(ctx, 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) } formatID := job.SelectedFormat.ID h.sendDocument(ctx, job.ChatID, f, job.Title) os.Remove(f) _ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{ URL: job.URL, Title: job.Title, MediaType: job.MediaType.String(), FormatID: formatID, Container: job.Container, FileSize: job.FileSize, Status: "completed", }) h.editText(ctx, job.ChatID, job.MessageID, "Download completed!", nil) return } } h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", 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) job := h.getActiveJob(userID) if job != nil { dl.CancelDownload(job) h.mu.Lock() delete(h.activeJobs, userID) h.mu.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 { return } ss.Step-- switch ss.Step { case -1: h.clearStepState(chatID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) case 0: // Re-show format list deduped := dl.DeduplicateResolutions(ss.MediaInfo.Formats) var videoFmts, audioFmts []domain.Format for _, f := range deduped { if f.HasVideo { videoFmts = append(videoFmts, f) } else { audioFmts = append(audioFmts, f) } } ss.FormatIDs = []string{"best"} labels := []string{"Best"} for _, f := range videoFmts { ss.FormatIDs = append(ss.FormatIDs, f.ID) labels = append(labels, formatLabelForDisplay(f)) } for _, f := range audioFmts { ss.FormatIDs = append(ss.FormatIDs, f.ID) labels = append(labels, formatLabelForDisplay(f)) } 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) } func formatLabelForDisplay(f domain.Format) string { if f.Resolution != "" { label := f.Resolution 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 } // Audio-only with no bitrate: show format ID + extension if f.IsAudioOnly { label := f.ID if f.Extension != "" { label += " (" + f.Extension + ")" } if f.Filesize > 0 { label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) } return label } return f.ID } // 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 != "" { photo := &models.InputFileString{Data: info.Thumbnail} _, err := h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{ ChatID: chatID, Photo: photo, Caption: caption, }) if err == nil { return } slog.Warn("send thumbnail failed, falling back to text", "error", err) } h.sendText(ctx, chatID, caption) } 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) }