diff --git a/README.md b/README.md index 3605a80..64be7cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go index e94d4b3..640e417 100644 --- a/internal/dl/ytdlp.go +++ b/internal/dl/ytdlp.go @@ -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") } diff --git a/internal/handler/download.go b/internal/handler/download.go index e6400d2..532bfba 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -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 diff --git a/internal/handler/settings.go b/internal/handler/settings.go index d03f3ee..e100eca 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -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": diff --git a/internal/repo/store.go b/internal/repo/store.go index dac0613..f867aa2 100644 --- a/internal/repo/store.go +++ b/internal/repo/store.go @@ -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