feat: add 'Ask' quality option, language auto-select, and m4a audio remux
All checks were successful
CI / build (push) Successful in 47s

- Add 'ask' option to audio/video quality settings (stored as '' in DB)
- Auto-select when both media type AND quality are set; show filtered
  format picker when quality is Ask
- Auto-skip language picker when user has a matching language preference
- Remux audio-only downloads to m4a instead of webm for Telegram compat
- Update README to document the new flow and m4a audio output
This commit is contained in:
2026-06-28 12:17:08 +03:30
parent d0be877c0e
commit 3099244614
5 changed files with 85 additions and 12 deletions

View File

@@ -10,11 +10,12 @@ Telegram bot for downloading media from any site yt-dlp supports.
- Thumbnail preview with metadata (title, uploader, duration) before download
- Audio-only sites (Spotify, SoundCloud, YouTube Music, etc.) auto-detect and skip to audio quality selection
- Multi-step format selection: type picker (Video/Audio/Ask), quality, secondary format (video+audio combine), language
- Auto-select: skip the format picker entirely when defaults are set in user preferences
- Auto-select: skip all pickers when both media type AND quality are set in preferences (quality "Ask" shows a filtered picker)
- Audio remuxed to m4a (Telegram-friendly), video remuxed to mp4
- Download progress updates (with speed and ETA)
- Cancel downloads at any time (terminates yt-dlp process)
- Per-user quota system (daily, weekly, monthly; 0 = unlimited)
- User preferences (default media type, audio/video quality, language)
- User preferences (default media type, audio/video quality with "Ask" option, language)
- Download history with pagination
- Configurable file size limit (default 50 MB)
- Cookies support for age-restricted and authenticated content

View File

@@ -250,8 +250,10 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
args = append(args, "--output", outputPath)
// Remux to mp4 for video output — Telegram-friendly container.
if job.MediaType != domain.MediaTypeAudio {
// Remux to Telegram-friendly containers: mp4 for video, m4a for audio-only.
if job.MediaType == domain.MediaTypeAudio {
args = append(args, "--merge-output-format", "m4a")
} else {
args = append(args, "--merge-output-format", "mp4")
}

View File

@@ -161,7 +161,27 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk {
h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs)
// 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
}
@@ -239,6 +259,34 @@ func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userI
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)
@@ -394,6 +442,18 @@ func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int
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

View File

@@ -14,11 +14,11 @@ import (
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
audioFmt := prefs.DefaultAudioFormat
if audioFmt == "" {
audioFmt = "best"
audioFmt = "ask"
}
videoFmt := prefs.DefaultVideoFormat
if videoFmt == "" {
videoFmt = "best"
videoFmt = "ask"
}
langLabel := prefs.DefaultLanguage
if langLabel == "" {
@@ -101,11 +101,13 @@ func (h *Handler) buildQualityAudioVideoPicker(prefs *domain.UserPreferences) (s
}
func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
bitrates := []string{"best", "128k", "192k", "256k", "320k"}
bitrates := []string{"ask", "best", "128k", "192k", "256k", "320k"}
rows := [][]models.InlineKeyboardButton{}
for _, b := range bitrates {
label := b
if b == prefs.DefaultAudioFormat {
if b == "ask" && prefs.DefaultAudioFormat == "" {
label = "> " + label
} else if b != "ask" && b == prefs.DefaultAudioFormat {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
@@ -119,12 +121,14 @@ func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string
}
func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
resolutions := []string{"best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
resolutions := []string{"ask", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, r := range resolutions {
label := r
if r == prefs.DefaultVideoFormat {
if r == "ask" && prefs.DefaultVideoFormat == "" {
label = "> " + label
} else if r != "ask" && r == prefs.DefaultVideoFormat {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{
@@ -220,9 +224,15 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
}
changed = true
case "audioquality":
if value == "ask" {
value = ""
}
prefs.DefaultAudioFormat = value
changed = true
case "videoquality":
if value == "ask" {
value = ""
}
prefs.DefaultVideoFormat = value
changed = true
case "language":

View File

@@ -65,7 +65,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (int64, error) {
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_preferences (user_id, default_media_type) VALUES (?, 'ask')",
"INSERT OR IGNORE INTO user_preferences (user_id, default_media_type, default_audio_format, default_video_format) VALUES (?, 'ask', '', '')",
userID,
); err != nil {
return nil, err