refactor: redesign format selection with unified list and independent audio/video picking
All checks were successful
CI / build (push) Successful in 52s
All checks were successful
CI / build (push) Successful in 52s
- Replace type/quality selection flow with unified format list (video+audio combined) - Add independent audio/video format picking: video-only formats prompt for audio track, audio-only formats offer optional video addition (skip when content is audio-only) - Fix formatKeyboard to use format IDs as callback data instead of display labels - Remove dead mediaTypeKeyboard function - Fix double-close panic in readProgress (caller is sole owner of updates channel) - Add FormatString field to DownloadJob for composite format specs (e.g. '137+140') - Change quota calculation from sum of all format sizes to max single format size - Fix 'Delete all data' button to use danger style with proper text - Clean up routePrefixedCallback: remove type_/quality_, add format_/secondary_ routes - Fix back navigation to rebuild format IDs list
This commit is contained in:
@@ -61,8 +61,8 @@ func parseProgressLine(line string) *progressUpdate {
|
||||
}
|
||||
|
||||
// readProgress reads yt-dlp stderr and sends progress updates to the channel.
|
||||
// The caller is responsible for closing the updates channel.
|
||||
func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
|
||||
defer close(updates)
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
@@ -110,6 +110,7 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
||||
}
|
||||
|
||||
langSet := make(map[string]bool)
|
||||
var maxSize int64
|
||||
for _, f := range info.Formats {
|
||||
ff := c.convertFormat(f)
|
||||
mi.Formats = append(mi.Formats, ff)
|
||||
@@ -117,10 +118,11 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
||||
langSet[ff.Language] = true
|
||||
mi.Languages = append(mi.Languages, ff.Language)
|
||||
}
|
||||
if ff.Filesize > 0 {
|
||||
mi.EstimatedSize += ff.Filesize
|
||||
if ff.Filesize > maxSize {
|
||||
maxSize = ff.Filesize
|
||||
}
|
||||
}
|
||||
mi.EstimatedSize = maxSize
|
||||
|
||||
return mi, nil
|
||||
}
|
||||
@@ -215,6 +217,10 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
||||
formatID := job.SelectedFormat.ID
|
||||
var args []string
|
||||
|
||||
// Use explicit format string if set (video+audio combination)
|
||||
if job.FormatString != "" {
|
||||
args = append(args, "-f", job.FormatString)
|
||||
} else {
|
||||
// Build format string for merge
|
||||
switch job.MediaType {
|
||||
case domain.MediaTypeAudio:
|
||||
@@ -231,6 +237,7 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
||||
args = append(args, "--merge-output-format", job.Container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output template with job ID prefix for reliable file finding
|
||||
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
|
||||
|
||||
@@ -117,6 +117,7 @@ type DownloadJob struct {
|
||||
URL string
|
||||
MediaType MediaType
|
||||
SelectedFormat *Format
|
||||
FormatString string
|
||||
Container string
|
||||
Language string
|
||||
Status DownloadStatus
|
||||
|
||||
@@ -17,21 +17,20 @@ import (
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
// Step state stored per user during the download flow
|
||||
type stepState struct {
|
||||
URL string
|
||||
MediaInfo *domain.MediaInfo
|
||||
Step int // 0=type, 1=quality, 2=language, 3=container
|
||||
MediaType domain.MediaType
|
||||
Quality string
|
||||
Step int
|
||||
MainFormatID string
|
||||
MainFormat *domain.Format
|
||||
SecondaryID string
|
||||
SecondaryFmt *domain.Format
|
||||
Language string
|
||||
Container string
|
||||
FormatIDs []string
|
||||
PlaylistState *domain.PlaylistState
|
||||
URLMessageID int // original URL message ID for editing
|
||||
}
|
||||
|
||||
// getUserStepState retrieves the current step state for a user.
|
||||
func (h *Handler) getUserStepState(chatID int64) *stepState {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -58,23 +57,22 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
||||
return
|
||||
}
|
||||
|
||||
// Check for active job
|
||||
if job := h.getActiveJob(userID); job != nil {
|
||||
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check quota
|
||||
info, err := h.YtDlp.GetMediaInfo(url)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL") {
|
||||
switch {
|
||||
case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"):
|
||||
h.sendText(ctx, chatID, "This URL is not supported.")
|
||||
} else if strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies") {
|
||||
case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"):
|
||||
h.sendText(ctx, chatID, "This content requires authentication (cookies).")
|
||||
} else if strings.Contains(errMsg, "live") {
|
||||
case strings.Contains(errMsg, "live"):
|
||||
h.sendText(ctx, chatID, "Cannot download live streams.")
|
||||
} else {
|
||||
default:
|
||||
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg))
|
||||
}
|
||||
return
|
||||
@@ -90,7 +88,6 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
||||
return
|
||||
}
|
||||
|
||||
// Check quota before starting flow
|
||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
||||
if err == nil {
|
||||
q = h.resetQuotasIfNeeded(q)
|
||||
@@ -112,124 +109,190 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
||||
MediaInfo: info,
|
||||
Step: 0,
|
||||
}
|
||||
h.setUserStepState(chatID, ss)
|
||||
|
||||
// Show thumbnail and metadata preview
|
||||
h.sendMediaPreview(ctx, chatID, info)
|
||||
|
||||
if info.IsAudioOnlyContent() {
|
||||
// Audio-only sites: skip type selection, go straight to quality
|
||||
ss.MediaType = domain.MediaTypeAudio
|
||||
ss.Step = 1
|
||||
filtered := dl.FilterFormatsByType(info.Formats, domain.MediaTypeAudio)
|
||||
if len(filtered) == 0 {
|
||||
h.sendText(ctx, chatID, "No audio formats available for this URL.")
|
||||
return
|
||||
// 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)
|
||||
}
|
||||
deduped := dl.DeduplicateResolutions(filtered)
|
||||
ss.FormatIDs = make([]string, len(deduped))
|
||||
labels := make([]string, len(deduped))
|
||||
for i, f := range deduped {
|
||||
ss.FormatIDs[i] = f.ID
|
||||
labels[i] = formatLabelForDisplay(f)
|
||||
}
|
||||
ss.FormatIDs = append([]string{"best"}, ss.FormatIDs...)
|
||||
labels = append([]string{"Best"}, labels...)
|
||||
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("quality_", labels)
|
||||
h.sendWithKB(ctx, chatID, "Select audio quality:", kb)
|
||||
return
|
||||
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
||||
h.sendWithKB(ctx, chatID, "Select format:", kb)
|
||||
}
|
||||
|
||||
kb := mediaTypeKeyboard()
|
||||
h.sendWithKB(ctx, chatID, fmt.Sprintf("Title: %s\n\nSelect media type:", info.Title), kb)
|
||||
}
|
||||
|
||||
// handleTypeSelection processes the media type choice.
|
||||
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, choice string) {
|
||||
// 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
|
||||
}
|
||||
switch choice {
|
||||
case "audio":
|
||||
ss.MediaType = domain.MediaTypeAudio
|
||||
case "video":
|
||||
ss.MediaType = domain.MediaTypeVideo
|
||||
case "both":
|
||||
ss.MediaType = domain.MediaTypeVideoAudio
|
||||
default:
|
||||
ss.MainFormatID = formatID
|
||||
|
||||
if formatID == "best" {
|
||||
ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true}
|
||||
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
||||
return
|
||||
}
|
||||
ss.Step = 1
|
||||
|
||||
// Filter and deduplicate formats by type
|
||||
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, ss.MediaType)
|
||||
if len(filtered) == 0 {
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("No %s formats available for this URL.", choice), &models.InlineKeyboardMarkup{
|
||||
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)
|
||||
ss.FormatIDs = make([]string, len(deduped))
|
||||
ids := make([]string, len(deduped))
|
||||
labels := make([]string, len(deduped))
|
||||
for i, f := range deduped {
|
||||
ss.FormatIDs[i] = f.ID
|
||||
ids[i] = f.ID
|
||||
labels[i] = formatLabelForDisplay(f)
|
||||
}
|
||||
// Add "Best" option first
|
||||
ss.FormatIDs = append([]string{"best"}, ss.FormatIDs...)
|
||||
labels = append([]string{"Best"}, labels...)
|
||||
|
||||
ss.FormatIDs = ids
|
||||
h.setUserStepState(chatID, ss)
|
||||
kb := formatKeyboard("quality_", labels)
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Title: %s\n\nSelect quality:", ss.MediaInfo.Title), &kb)
|
||||
|
||||
prompt := fmt.Sprintf("Select %s format to combine:", kind)
|
||||
kb := formatKeyboard("secondary_", labels, ids)
|
||||
h.editText(ctx, chatID, msgID, prompt, &kb)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return f.ID
|
||||
}
|
||||
|
||||
// handleQualitySelection processes the quality choice.
|
||||
func (h *Handler) handleQualitySelection(ctx context.Context, chatID int64, msgID int, userID int64, quality string) {
|
||||
// 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
|
||||
}
|
||||
ss.Quality = quality
|
||||
ss.Step = 2
|
||||
|
||||
// Check if we need language selection
|
||||
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)
|
||||
}
|
||||
|
||||
// Skip to container selection
|
||||
// advanceToContainer shows the container picker.
|
||||
func (h *Handler) advanceToContainer(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
||||
ss.Step = 3
|
||||
containers := dl.ContainerOptions(ss.MediaType)
|
||||
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)
|
||||
@@ -237,15 +300,10 @@ func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msg
|
||||
return
|
||||
}
|
||||
ss.Language = language
|
||||
ss.Step = 3
|
||||
h.setUserStepState(chatID, ss)
|
||||
|
||||
containers := dl.ContainerOptions(ss.MediaType)
|
||||
kb := containerKeyboard(containers)
|
||||
h.editText(ctx, chatID, msgID, "Select container format:", &kb)
|
||||
h.advanceToContainer(ctx, chatID, msgID, userID, ss)
|
||||
}
|
||||
|
||||
// handleContainerSelection processes the container format choice and starts the download.
|
||||
// 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 {
|
||||
@@ -253,24 +311,23 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
||||
}
|
||||
ss.Container = container
|
||||
|
||||
// Build the format object
|
||||
format := &domain.Format{ID: ss.Quality}
|
||||
for _, f := range ss.MediaInfo.Formats {
|
||||
if f.ID == ss.Quality {
|
||||
format = &f
|
||||
break
|
||||
}
|
||||
mediaType := ss.determineMediaType()
|
||||
formatString := ss.buildFormatString()
|
||||
|
||||
format := &domain.Format{ID: ss.MainFormatID}
|
||||
if ss.MainFormat != nil {
|
||||
format = ss.MainFormat
|
||||
}
|
||||
|
||||
// Create the download job
|
||||
job := &domain.DownloadJob{
|
||||
ID: h.generateJobID(),
|
||||
UserID: userID,
|
||||
ChatID: chatID,
|
||||
MessageID: msgID,
|
||||
URL: ss.URL,
|
||||
MediaType: ss.MediaType,
|
||||
MediaType: mediaType,
|
||||
SelectedFormat: format,
|
||||
FormatString: formatString,
|
||||
Container: container,
|
||||
Language: ss.Language,
|
||||
Status: domain.StatusDownloading,
|
||||
@@ -281,13 +338,11 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
||||
h.setActiveJob(userID, job)
|
||||
h.clearStepState(chatID)
|
||||
|
||||
// Start download in background
|
||||
go h.runDownload(context.Background(), job)
|
||||
}
|
||||
|
||||
// runDownload manages the download lifecycle, progress updates, and completion.
|
||||
// runDownload manages the download lifecycle, progress, and completion.
|
||||
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||
// Show initial progress message
|
||||
kb := progressKeyboard(0, "", "")
|
||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: job.ChatID,
|
||||
@@ -316,7 +371,6 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||
currentJob := h.activeJobs[job.UserID]
|
||||
h.mu.Unlock()
|
||||
if currentJob == nil {
|
||||
// Job was cancelled
|
||||
return
|
||||
}
|
||||
|
||||
@@ -336,11 +390,9 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress every ~5%
|
||||
if upd.Progress-lastProgress >= domain.ProgressThreshold || upd.Status == domain.StatusCompleted {
|
||||
lastProgress = upd.Progress
|
||||
|
||||
// Apply quota at >50%
|
||||
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
|
||||
job.QuotaApplied = true
|
||||
h.applyQuota(job.UserID, job.FileSize)
|
||||
@@ -352,17 +404,15 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||
}
|
||||
}
|
||||
|
||||
// Download completed
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
|
||||
if job.Status == domain.StatusCompleted {
|
||||
// Find the downloaded file using job ID prefix
|
||||
pattern := filepath.Join(h.downloadDir, job.ID+"_*")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err == nil && len(files) > 0 {
|
||||
f := files[0] // take the first match
|
||||
f := files[0]
|
||||
fileInfo, err := os.Stat(f)
|
||||
if err == nil {
|
||||
if fileInfo.Size() > h.maxFileSize {
|
||||
@@ -448,30 +498,41 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c
|
||||
ss.Step--
|
||||
switch ss.Step {
|
||||
case -1:
|
||||
// Go back to URL input
|
||||
h.clearStepState(chatID)
|
||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||
case 0:
|
||||
if ss.MediaInfo != nil && ss.MediaInfo.IsAudioOnlyContent() {
|
||||
// Audio-only: go back to URL input directly
|
||||
h.clearStepState(chatID)
|
||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||
return
|
||||
// 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)
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "Select media type:", &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
|
||||
})
|
||||
}
|
||||
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 quality selector
|
||||
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, ss.MediaType)
|
||||
deduped := dl.DeduplicateResolutions(filtered)
|
||||
labels := make([]string, len(deduped))
|
||||
for i, f := range deduped {
|
||||
labels[i] = formatLabelForDisplay(f)
|
||||
// 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)
|
||||
}
|
||||
labels = append([]string{"Best"}, labels...)
|
||||
kb := formatKeyboard("quality_", labels)
|
||||
h.editText(ctx, chatID, msgID, "Select quality:", &kb)
|
||||
case 2:
|
||||
if len(ss.MediaInfo.Languages) > 1 {
|
||||
kb := languageKeyboard(ss.MediaInfo.Languages)
|
||||
@@ -495,6 +556,24 @@ func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID
|
||||
"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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -231,8 +231,7 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
|
||||
h.handleHelp(ctx, cb.Message.Message)
|
||||
h.answerCb(ctx, cb.ID)
|
||||
case data == "history":
|
||||
h.handleHistory(ctx, cb.Message.Message, userID)
|
||||
h.answerCb(ctx, cb.ID)
|
||||
h.historyCallback(ctx, chatID, msgID, cb.ID, userID)
|
||||
case data == "pending_cancel":
|
||||
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
|
||||
default:
|
||||
@@ -244,10 +243,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
||||
defer h.answerCb(ctx, cbID)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(data, "type_"):
|
||||
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
|
||||
case strings.HasPrefix(data, "quality_"):
|
||||
h.handleQualitySelection(ctx, chatID, msgID, userID, data[8:])
|
||||
case strings.HasPrefix(data, "format_"):
|
||||
h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:])
|
||||
case strings.HasPrefix(data, "secondary_"):
|
||||
h.handleSecondarySelection(ctx, chatID, msgID, userID, data[10:])
|
||||
case strings.HasPrefix(data, "lang_"):
|
||||
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
||||
case strings.HasPrefix(data, "container_"):
|
||||
@@ -262,14 +261,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
||||
h.handlePlaylistConfirm(ctx, chatID, msgID, userID)
|
||||
case data == "back_settings":
|
||||
h.backToSettings(ctx, chatID, msgID, userID)
|
||||
case data == "delaccount_prompt":
|
||||
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
|
||||
case strings.HasPrefix(data, "delaccount_"):
|
||||
h.deleteAccountConfirm(ctx, chatID, msgID, userID)
|
||||
case strings.HasPrefix(data, "settings_"):
|
||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:])
|
||||
case strings.HasPrefix(data, "settings_set_"):
|
||||
h.handleSettingsSet(ctx, chatID, msgID, userID, data[13:])
|
||||
case strings.HasPrefix(data, "settings_"):
|
||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,8 +362,10 @@ func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string)
|
||||
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
h.clearActiveJob(userID)
|
||||
h.editText(ctx, chatID, msgID, "Main Menu:", nil)
|
||||
h.sendWithKB(ctx, chatID, "Main Menu:", mainKeyboard())
|
||||
h.editText(ctx, chatID, msgID, "Main Menu:", &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: mainKeyboard().InlineKeyboard,
|
||||
})
|
||||
h.clearStepState(chatID)
|
||||
}
|
||||
|
||||
func (h *Handler) clearActiveJob(userID int64) {
|
||||
@@ -451,7 +448,7 @@ func (h *Handler) handleSettings(ctx context.Context, msg *models.Message, userI
|
||||
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, userID int64) {
|
||||
records, err := h.Repo.GetHistory(userID, 10, 0)
|
||||
if err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading history")
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading history.")
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
@@ -465,6 +462,37 @@ func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, userID
|
||||
h.sendText(ctx, msg.Chat.ID, text)
|
||||
}
|
||||
|
||||
// historyCallback handles the history button from the main menu.
|
||||
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
records, err := h.Repo.GetHistory(userID, 10, 0)
|
||||
if err != nil {
|
||||
h.editText(ctx, chatID, msgID, "Error loading history.", &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
h.editText(ctx, chatID, msgID, "No download history yet.", &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
text := "Recent downloads:\n"
|
||||
for i, r := range records {
|
||||
text += fmt.Sprintf("%d. %s (%s)\n", i+1, r.Title, formatBytes(r.FileSize))
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, text, &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// applyQuota adds file size to the user's quota counters.
|
||||
func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
||||
|
||||
@@ -40,27 +40,14 @@ func settingsBackKeyboard() models.InlineKeyboardMarkup {
|
||||
}
|
||||
}
|
||||
|
||||
// mediaTypeKeyboard builds the three-button row for media type selection + Cancel.
|
||||
func mediaTypeKeyboard() models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "Audio Only", CallbackData: "type_audio"},
|
||||
{Text: "Video Only", CallbackData: "type_video"},
|
||||
{Text: "Video+Audio", CallbackData: "type_both"},
|
||||
},
|
||||
{{Text: "Cancel", CallbackData: "cancel_dl"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// formatKeyboard builds a keyboard from a slice of format labels.
|
||||
func formatKeyboard(prefix string, labels []string) models.InlineKeyboardMarkup {
|
||||
// formatKeyboard builds a 2-column inline keyboard from labels with corresponding callback data values.
|
||||
// The prefix is prepended to each data value to form the full callback data.
|
||||
func formatKeyboard(prefix string, labels, data []string) models.InlineKeyboardMarkup {
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, label := range labels {
|
||||
data := prefix + label
|
||||
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: data})
|
||||
cb := prefix + data[i]
|
||||
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: cb})
|
||||
if len(row) == 2 || i == len(labels)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
@@ -75,21 +62,7 @@ func formatKeyboard(prefix string, labels []string) models.InlineKeyboardMarkup
|
||||
|
||||
// languageKeyboard builds a keyboard from available languages.
|
||||
func languageKeyboard(languages []string) models.InlineKeyboardMarkup {
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, lang := range languages {
|
||||
data := "lang_" + lang
|
||||
row = append(row, models.InlineKeyboardButton{Text: lang, CallbackData: data})
|
||||
if len(row) == 2 || i == len(languages)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back", CallbackData: "back_step"},
|
||||
{Text: "Cancel", CallbackData: "cancel_dl"},
|
||||
})
|
||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
return formatKeyboard("lang_", languages, languages)
|
||||
}
|
||||
|
||||
// containerKeyboard builds keyboard for container format selection.
|
||||
@@ -214,7 +187,7 @@ func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", userID)},
|
||||
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", userID), Style: "danger"},
|
||||
{Text: "Cancel", CallbackData: "back_settings"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
|
||||
{Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"},
|
||||
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
|
||||
},
|
||||
{{Text: "Delete my data", CallbackData: "delaccount_prompt"}},
|
||||
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
}
|
||||
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
@@ -232,7 +232,7 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
|
||||
// deleteAccountPrompt shows the deletion confirmation prompt.
|
||||
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
kb := deleteConfirmKeyboard(userID)
|
||||
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (preferences, quota, history).\nAre you sure?", &kb)
|
||||
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (preferences, quotas, history).\nAre you sure?", &kb)
|
||||
}
|
||||
|
||||
// deleteAccountConfirm permanently deletes all user data.
|
||||
|
||||
Reference in New Issue
Block a user