feat: add 'Ask' quality option, language auto-select, and m4a audio remux
All checks were successful
CI / build (push) Successful in 47s
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user