From b11b5649a5d5c476ed2024245e69340f0d2b1b14 Mon Sep 17 00:00:00 2001 From: db123 Date: Sun, 28 Jun 2026 11:28:53 +0330 Subject: [PATCH] feat: add auto-select from preferences and type picker - Add MediaTypeAsk (0) as the default, mapping old video_audio to Ask - Show Video/Audio/Ask type picker when no default media type is set - Auto-select matching format and skip picker when defaults are configured - Add autoSelectVideoFormat / autoSelectAudioFormat helpers with closest-match - Add buildFilteredFormatList for type-filtered format lists - Add Media Type setting in settings UI (Ask/Video/Audio) - Add --merge-output-format mp4 for video downloads via yt-dlp - Fix formatLabelForDisplay to show Height fallback and Extension - Add cookies volume mount to docker-compose.yml - Document cookies setup and new features in README --- README.md | 18 +- docker-compose.yml | 1 + internal/dl/ytdlp.go | 5 + internal/domain/types.go | 5 +- internal/domain/types_test.go | 2 +- internal/handler/download.go | 281 ++++++++++++++++++++++++++++++- internal/handler/handler.go | 2 + internal/handler/handler_test.go | 203 ++++++++++++++++++++++ internal/handler/keyboard.go | 16 ++ internal/handler/settings.go | 51 +++++- internal/repo/store.go | 8 +- internal/repo/store_test.go | 4 +- 12 files changed, 578 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 432ab59..3605a80 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ Telegram bot for downloading media from any site yt-dlp supports. - DRM fallback: automatically searches YouTube when Spotify/DRM-protected content is detected - 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: quality, secondary format (audio/video combine), language +- 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 - 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 audio/video quality, language) +- User preferences (default media type, audio/video quality, language) - Download history with pagination - Configurable file size limit (default 50 MB) -- Cookies support for age-restricted content +- Cookies support for age-restricted and authenticated content - Proxy support via HTTP_PROXY/HTTPS_PROXY env vars - Webhook and polling modes - SQLite database for persistence @@ -52,6 +53,17 @@ Copy `.env.example` to `.env` and configure: | `BOT_TLS_CERT` | empty | TLS certificate path | | `BOT_TLS_KEY` | empty | TLS key path | +### Cookies (for age-restricted and authenticated content) + +Some sites (YouTube, Spotify, etc.) require cookies for age-restricted content. To set up: + +1. Install a browser extension that exports cookies in Netscape format (e.g., "Get cookies.txt" for Chrome/Firefox). +2. Log into the site with a throwaway account. +3. Export cookies to a file named `cookies.txt` in the project root. +4. Set `COOKIES_FILE=/cookies.txt` in your `.env` file. + +For Docker, the cookie file is automatically mounted from `./cookies.txt` (see `docker-compose.yml`). + ## Running ### Polling mode (default) diff --git a/docker-compose.yml b/docker-compose.yml index dccad19..6e73f48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - TZ=${TZ} volumes: - uptodown_data:/data + - ./cookies.txt:/cookies.txt:ro # Optional: cookies file for yt-dlp auth volumes: uptodown_data: diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go index a47b8f2..e94d4b3 100644 --- a/internal/dl/ytdlp.go +++ b/internal/dl/ytdlp.go @@ -250,6 +250,11 @@ 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 { + args = append(args, "--merge-output-format", "mp4") + } + // --progress forces progress output in non-TTY (pipe) environments // --newline uses \n instead of \r so bufio.Scanner can read line-by-line args = append(args, "--progress", "--newline") diff --git a/internal/domain/types.go b/internal/domain/types.go index 5dfbe29..ca4dc84 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -6,13 +6,16 @@ import "os/exec" type MediaType int const ( - MediaTypeAudio MediaType = iota + 1 + MediaTypeAsk MediaType = iota + MediaTypeAudio MediaTypeVideo MediaTypeVideoAudio ) func (m MediaType) String() string { switch m { + case MediaTypeAsk: + return "ask" case MediaTypeAudio: return "audio" case MediaTypeVideo: diff --git a/internal/domain/types_test.go b/internal/domain/types_test.go index 60091cb..f9c49d1 100644 --- a/internal/domain/types_test.go +++ b/internal/domain/types_test.go @@ -10,7 +10,7 @@ func TestMediaTypeString(t *testing.T) { {MediaTypeAudio, "audio"}, {MediaTypeVideo, "video"}, {MediaTypeVideoAudio, "video_audio"}, - {MediaType(0), "unknown"}, + {MediaTypeAsk, "ask"}, {MediaType(99), "unknown"}, } for _, tt := range tests { diff --git a/internal/handler/download.go b/internal/handler/download.go index afa1970..e6400d2 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -24,6 +24,7 @@ type stepState struct { URL string MediaInfo *domain.MediaInfo Step int + SelectedType string // "video", "audio", "ask", or "" when not yet chosen MainFormatID string MainFormat *domain.Format SecondaryID string @@ -158,6 +159,34 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, h.sendMediaPreview(ctx, chatID, info) + prefs, prefErr := h.Repo.GetOrCreatePreferences(userID) + if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk { + h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs) + return + } + + ss := &stepState{ + URL: url, + MediaInfo: info, + Step: 0, + CreatedAt: time.Now(), + } + h.setUserStepState(chatID, ss) + + kb := mediaTypeKeyboard() + h.sendWithKB(ctx, chatID, "Select media type:", kb) +} + +// autoSelectAndDownload picks the best matching format from preferences and +// skips the picker entirely. +func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo, prefs *domain.UserPreferences) { + msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: "Processing..."}) + if err != nil { + slog.Warn("send processing message failed", "error", err) + return + } + msgID := msg.ID + ss := &stepState{ URL: url, MediaInfo: info, @@ -165,14 +194,74 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, CreatedAt: time.Now(), } - ss.FormatIDs, _ = buildFormatList(info.Formats) + var mainID, secondaryID string + switch prefs.DefaultMediaType { + case domain.MediaTypeVideo: + mainID, secondaryID = autoSelectVideoFormat(info.Formats, prefs.DefaultVideoFormat) + case domain.MediaTypeAudio: + mainID = autoSelectAudioFormat(info.Formats, prefs.DefaultAudioFormat) + default: + mainID = "best" + } + + if mainID == "" { + mainID = "best" + } + ss.MainFormatID = mainID + + if mainID == "best" { + if info.IsAudioOnlyContent() { + ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true} + } else { + ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true} + } + } else { + for i := range info.Formats { + f := &info.Formats[i] + if f.ID == mainID { + ss.MainFormat = f + break + } + } + } + + if secondaryID != "" { + ss.SecondaryID = secondaryID + for i := range info.Formats { + f := &info.Formats[i] + if f.ID == secondaryID { + ss.SecondaryFmt = f + break + } + } + } + + h.advanceToLanguage(ctx, chatID, msgID, userID, ss) +} + +// 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) + if ss == nil { + slog.Warn("nil stepState in handleTypeSelection", "chat_id", chatID, "user_id", userID) + return + } + ss.SelectedType = mediaType + + ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType) + if err != nil { + h.editText(ctx, chatID, msgID, "No formats available for this type.", nil) + return + } + ss.FormatIDs = ids h.setUserStepState(chatID, ss) - labels := make([]string, len(ss.FormatIDs)) - for i, id := range ss.FormatIDs { + + labels := make([]string, len(ids)) + for i, id := range ids { if id == "best" { labels[i] = "Best" } else { - f := findFormatByID(info.Formats, id) + f := findFormatByID(ss.MediaInfo.Formats, id) if f != nil { labels[i] = formatLabelForDisplay(*f) } else { @@ -180,8 +269,8 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, } } } - kb := formatKeyboard("format_", labels, ss.FormatIDs) - h.sendWithKB(ctx, chatID, "Select format:", kb) + kb := formatKeyboard("format_", labels, ids) + h.editText(ctx, chatID, msgID, "Select format:", &kb) } // handleFormatSelection processes a format pick from the unified list. @@ -612,13 +701,22 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c return } + // If on the format list with a selected type, go back to type picker. + if ss.Step == 0 && ss.SelectedType != "" { + ss.SelectedType = "" + h.setUserStepState(chatID, ss) + kb := mediaTypeKeyboard() + h.editText(ctx, chatID, msgID, "Select media type:", &kb) + return + } + ss.Step-- switch ss.Step { case -1: h.clearStepState(chatID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) case 0: - ss.FormatIDs, _ = buildFormatList(ss.MediaInfo.Formats) + ss.FormatIDs, _ = buildFilteredFormatList(ss.MediaInfo.Formats, "") labels := make([]string, len(ss.FormatIDs)) for i, id := range ss.FormatIDs { if id == "best" { @@ -685,6 +783,162 @@ func buildFormatList(formats []domain.Format) ([]string, error) { return ids, nil } +// buildFilteredFormatList returns format IDs filtered by media type for the type picker. +// When mediaType is "video", only formats with video are returned. +// When mediaType is "audio", only audio-only formats are returned. +// When empty or "ask", all formats are returned (current behavior). +func buildFilteredFormatList(formats []domain.Format, mediaType string) ([]string, error) { + deduped := dl.DeduplicateResolutions(formats) + ids := make([]string, 0, len(deduped)+1) + ids = append(ids, "best") + + switch mediaType { + case "video": + for _, f := range deduped { + if f.HasVideo { + ids = append(ids, f.ID) + } + } + case "audio": + for _, f := range deduped { + if f.HasAudio && f.IsAudioOnly { + ids = append(ids, f.ID) + } + } + default: + for _, f := range deduped { + if f.HasVideo { + ids = append(ids, f.ID) + } + } + for _, f := range deduped { + if !f.HasVideo { + ids = append(ids, f.ID) + } + } + } + return ids, nil +} + +// autoSelectVideoFormat finds the closest video format to the preferred resolution. +// Returns the main format ID and optionally a secondary audio ID if the best match +// is video-only. Prefers formats at or above the target resolution, falling back to +// the closest below. Returns ("best", "") if the preference is "best" or no match. +func autoSelectVideoFormat(formats []domain.Format, preferredID string) (mainID, secondaryID string) { + if preferredID == "" || preferredID == "best" { + return "best", "" + } + + var targetHeight int + if n, err := fmt.Sscanf(preferredID, "%dp", &targetHeight); err != nil || n != 1 { + return "best", "" + } + + var bestFmt *domain.Format + bestDiff := math.MaxInt32 + + for i := range formats { + f := &formats[i] + if !f.HasVideo || f.Height == 0 { + continue + } + diff := f.Height - targetHeight + if diff >= 0 && diff < bestDiff { + bestDiff = diff + bestFmt = f + } + } + + if bestFmt == nil { + bestDiff = math.MaxInt32 + for i := range formats { + f := &formats[i] + if !f.HasVideo || f.Height == 0 { + continue + } + diff := targetHeight - f.Height + if diff >= 0 && diff < bestDiff { + bestDiff = diff + bestFmt = f + } + } + } + + if bestFmt == nil { + return "best", "" + } + + mainID = bestFmt.ID + + if bestFmt.HasVideo && !bestFmt.HasAudio { + for i := range formats { + f := &formats[i] + if f.HasAudio && f.IsAudioOnly { + secondaryID = f.ID + break + } + } + } + + return +} + +// autoSelectAudioFormat finds the closest audio format to the preferred bitrate. +// Returns the format ID, or "best" if the preference is "best" or no match. +func autoSelectAudioFormat(formats []domain.Format, preferredID string) string { + if preferredID == "" || preferredID == "best" { + return "best" + } + + var targetBitrate int + if n, err := fmt.Sscanf(preferredID, "%dk", &targetBitrate); err != nil || n != 1 { + return "best" + } + + var bestFmt *domain.Format + bestDiff := math.MaxInt32 + + for i := range formats { + f := &formats[i] + if !f.HasAudio || !f.IsAudioOnly { + continue + } + var bitrate int + if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 { + continue + } + diff := bitrate - targetBitrate + if diff >= 0 && diff < bestDiff { + bestDiff = diff + bestFmt = f + } + } + + if bestFmt == nil { + bestDiff = math.MaxInt32 + for i := range formats { + f := &formats[i] + if !f.HasAudio || !f.IsAudioOnly { + continue + } + var bitrate int + if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 { + continue + } + diff := targetBitrate - bitrate + if diff >= 0 && diff < bestDiff { + bestDiff = diff + bestFmt = f + } + } + } + + if bestFmt == nil { + return "best" + } + return bestFmt.ID +} + // findFormatByID returns the format with the given ID from the list, or nil. func findFormatByID(formats []domain.Format, id string) *domain.Format { for i := range formats { @@ -716,6 +970,13 @@ func formatLabelForDisplay(f domain.Format) string { } return label } + if f.Height > 0 { + label := fmt.Sprintf("%dp", f.Height) + if f.Filesize > 0 { + label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) + } + return label + } if f.Bitrate != "" { label := f.Bitrate if f.Filesize > 0 { @@ -723,7 +984,11 @@ func formatLabelForDisplay(f domain.Format) string { } return label } - return f.ID + label := f.ID + if f.Extension != "" { + label += " (" + f.Extension + ")" + } + return label } // sendMediaPreview sends a thumbnail and metadata info before the selection flow. diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 7fdda68..7147d48 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -273,6 +273,8 @@ 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, "format_"): h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:]) case strings.HasPrefix(data, "secondary_"): diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 756fa84..5e8645a 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -169,6 +169,209 @@ func TestFormatDuration(t *testing.T) { } } +func TestBuildFilteredFormatList(t *testing.T) { + formats := []domain.Format{ + {ID: "1", HasVideo: true, HasAudio: false, Resolution: "1920x1080"}, + {ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"}, + {ID: "3", HasVideo: true, HasAudio: false, Resolution: "1280x720"}, + {ID: "4", HasVideo: true, HasAudio: true, Resolution: "640x480"}, + {ID: "5", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"}, + } + + t.Run("ask type returns all formats", func(t *testing.T) { + ids, err := buildFilteredFormatList(formats, "ask") + if err != nil { + t.Fatalf("buildFilteredFormatList(ask) error: %v", err) + } + if ids[0] != "best" { + t.Errorf("first ID = %q, want best", ids[0]) + } + // Should include all non-best IDs + expectedIDs := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true} + for _, id := range ids[1:] { + if !expectedIDs[id] { + t.Errorf("unexpected ID %q in ask list", id) + } + delete(expectedIDs, id) + } + if len(expectedIDs) > 0 { + t.Errorf("missing IDs in ask list: %v", expectedIDs) + } + }) + + t.Run("video type only returns video formats", func(t *testing.T) { + ids, err := buildFilteredFormatList(formats, "video") + if err != nil { + t.Fatalf("buildFilteredFormatList(video) error: %v", err) + } + if ids[0] != "best" { + t.Errorf("first ID = %q, want best", ids[0]) + } + for _, id := range ids[1:] { + if id == "2" || id == "5" { + t.Errorf("video list contains audio-only ID %q", id) + } + } + }) + + t.Run("audio type only returns audio-only formats", func(t *testing.T) { + ids, err := buildFilteredFormatList(formats, "audio") + if err != nil { + t.Fatalf("buildFilteredFormatList(audio) error: %v", err) + } + if ids[0] != "best" { + t.Errorf("first ID = %q, want best", ids[0]) + } + for _, id := range ids[1:] { + if id == "1" || id == "3" || id == "4" { + t.Errorf("audio list contains video ID %q", id) + } + } + }) + + t.Run("default (empty) returns all formats", func(t *testing.T) { + ids, err := buildFilteredFormatList(formats, "") + if err != nil { + t.Fatalf("buildFilteredFormatList('') error: %v", err) + } + if len(ids) != 6 { // best + 5 formats + t.Errorf("got %d IDs, want 6", len(ids)) + } + }) +} + +func TestAutoSelectVideoFormat(t *testing.T) { + formats := []domain.Format{ + {ID: "137", HasVideo: true, HasAudio: false, Height: 1080, Width: 1920, Resolution: "1920x1080"}, + {ID: "136", HasVideo: true, HasAudio: false, Height: 720, Width: 1280, Resolution: "1280x720"}, + {ID: "135", HasVideo: true, HasAudio: false, Height: 480, Width: 854, Resolution: "854x480"}, + {ID: "247", HasVideo: true, HasAudio: true, Height: 1080, Width: 1920, Resolution: "1920x1080"}, + {ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"}, + } + + t.Run("best returns best, no secondary", func(t *testing.T) { + mainID, secondaryID := autoSelectVideoFormat(formats, "best") + if mainID != "best" { + t.Errorf("mainID = %q, want best", mainID) + } + if secondaryID != "" { + t.Errorf("secondaryID = %q, want empty", secondaryID) + } + }) + + t.Run("empty returns best", func(t *testing.T) { + mainID, _ := autoSelectVideoFormat(formats, "") + if mainID != "best" { + t.Errorf("mainID = %q, want best", mainID) + } + }) + + t.Run("exact match 1080p", func(t *testing.T) { + mainID, _ := autoSelectVideoFormat(formats, "1080p") + if mainID != "137" && mainID != "247" { + t.Errorf("mainID = %q, want 137 or 247", mainID) + } + }) + + t.Run("prefer above 360p", func(t *testing.T) { + mainID, _ := autoSelectVideoFormat(formats, "360p") + // 480p (135) is the closest above 360p; below is none + if mainID != "135" { + t.Errorf("mainID = %q, want 135 (480p is closest above 360p)", mainID) + } + }) + + t.Run("exact match 720p", func(t *testing.T) { + mainID, _ := autoSelectVideoFormat(formats, "720p") + // 720p (136) is an exact match + if mainID != "136" { + t.Errorf("mainID = %q, want 136 (exact match for 720p)", mainID) + } + }) + + t.Run("above 1080p takes 1080p as closest below", func(t *testing.T) { + mainID, _ := autoSelectVideoFormat(formats, "2160p") + // No 4K format, should fall back to 1080p (closest below) + if mainID != "137" && mainID != "247" { + t.Errorf("mainID = %q, want 137 or 247 (1080p is closest below 2160p)", mainID) + } + }) + + t.Run("video-only picks best audio secondary", func(t *testing.T) { + formats := []domain.Format{ + {ID: "137", HasVideo: true, HasAudio: false, Height: 1080}, + {ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"}, + } + _, secondaryID := autoSelectVideoFormat(formats, "1080p") + if secondaryID != "140" { + t.Errorf("secondaryID = %q, want 140", secondaryID) + } + }) + + t.Run("no match returns best", func(t *testing.T) { + formats := []domain.Format{ + {ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true}, + } + mainID, _ := autoSelectVideoFormat(formats, "1080p") + if mainID != "best" { + t.Errorf("mainID = %q, want best", mainID) + } + }) +} + +func TestAutoSelectAudioFormat(t *testing.T) { + formats := []domain.Format{ + {ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"}, + {ID: "256", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "256k"}, + {ID: "320", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"}, + } + + t.Run("best returns best", func(t *testing.T) { + id := autoSelectAudioFormat(formats, "best") + if id != "best" { + t.Errorf("got %q, want best", id) + } + }) + + t.Run("empty returns best", func(t *testing.T) { + id := autoSelectAudioFormat(formats, "") + if id != "best" { + t.Errorf("got %q, want best", id) + } + }) + + t.Run("exact match 256k", func(t *testing.T) { + id := autoSelectAudioFormat(formats, "256k") + if id != "256" { + t.Errorf("got %q, want 256", id) + } + }) + + t.Run("prefer above 192k", func(t *testing.T) { + id := autoSelectAudioFormat(formats, "192k") + if id != "256" { + t.Errorf("got %q, want 256 (closest above 192k)", id) + } + }) + + t.Run("above highest takes highest below", func(t *testing.T) { + id := autoSelectAudioFormat(formats, "384k") + if id != "320" { + t.Errorf("got %q, want 320 (closest below 384k)", id) + } + }) + + t.Run("no match returns best", func(t *testing.T) { + formats := []domain.Format{ + {ID: "1", HasVideo: true, HasAudio: true}, + } + id := autoSelectAudioFormat(formats, "128k") + if id != "best" { + t.Errorf("got %q, want best", id) + } + }) +} + func TestFormatLabelForDisplay(t *testing.T) { tests := []struct { f domain.Format diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go index f26eb47..1fa1766 100644 --- a/internal/handler/keyboard.go +++ b/internal/handler/keyboard.go @@ -80,6 +80,22 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku } } +// mediaTypeKeyboard builds the initial type picker (Video/Audio/Ask). +func mediaTypeKeyboard() models.InlineKeyboardMarkup { + return models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Video", CallbackData: "type_video"}, + {Text: "Audio", CallbackData: "type_audio"}, + {Text: "Ask", CallbackData: "type_ask"}, + }, + { + {Text: "Cancel", CallbackData: "cancel_dl"}, + }, + }, + } +} + // deleteConfirmKeyboard builds the confirmation prompt for account deletion. func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup { return models.InlineKeyboardMarkup{ diff --git a/internal/handler/settings.go b/internal/handler/settings.go index f65cd2f..d03f3ee 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -25,11 +25,22 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen langLabel = "Default" } + mediaTypeLabel := "Ask" + switch prefs.DefaultMediaType { + case domain.MediaTypeAudio: + mediaTypeLabel = "Audio" + case domain.MediaTypeVideo: + mediaTypeLabel = "Video" + } + rows := [][]models.InlineKeyboardButton{ { - {Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"}, + {Text: fmt.Sprintf("Media Type: %s", mediaTypeLabel), CallbackData: "settings_mediatype"}, {Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"}, }, + { + {Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"}, + }, {{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}}, } @@ -62,6 +73,9 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg } switch setting { + case "mediatype": + text, kb := h.buildMediaTypePicker(prefs) + h.editText(ctx, chatID, msgID, text, &kb) case "quality": text, kb := h.buildQualityAudioVideoPicker(prefs) h.editText(ctx, chatID, msgID, text, &kb) @@ -155,6 +169,31 @@ func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, mo return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows} } +func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { + options := []struct { + mt domain.MediaType + label string + }{ + {domain.MediaTypeAsk, "Ask"}, + {domain.MediaTypeVideo, "Video"}, + {domain.MediaTypeAudio, "Audio"}, + } + rows := [][]models.InlineKeyboardButton{} + for _, opt := range options { + label := opt.label + if opt.mt == prefs.DefaultMediaType { + label = "> " + label + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: "settings_set_mediatype_" + opt.mt.String()}, + }) + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Settings", CallbackData: "back_settings"}, + }) + return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) { parts := strings.SplitN(setting, "_", 2) if len(parts) < 2 { @@ -170,6 +209,16 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int changed := false switch action { + case "mediatype": + switch value { + case "ask": + prefs.DefaultMediaType = domain.MediaTypeAsk + case "audio": + prefs.DefaultMediaType = domain.MediaTypeAudio + case "video": + prefs.DefaultMediaType = domain.MediaTypeVideo + } + changed = true case "audioquality": prefs.DefaultAudioFormat = value changed = true diff --git a/internal/repo/store.go b/internal/repo/store.go index 655afa7..dac0613 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) VALUES (?)", + "INSERT OR IGNORE INTO user_preferences (user_id, default_media_type) VALUES (?, 'ask')", userID, ); err != nil { return nil, err @@ -90,14 +90,18 @@ func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) { p.DefaultMediaType = domain.MediaTypeAudio case "video": p.DefaultMediaType = domain.MediaTypeVideo + case "ask": + p.DefaultMediaType = domain.MediaTypeAsk default: - p.DefaultMediaType = domain.MediaTypeVideoAudio + p.DefaultMediaType = domain.MediaTypeAsk } return &p, nil } func mediaTypeToString(mt domain.MediaType) string { switch mt { + case domain.MediaTypeAsk: + return "ask" case domain.MediaTypeAudio: return "audio" case domain.MediaTypeVideo: diff --git a/internal/repo/store_test.go b/internal/repo/store_test.go index f1f420a..d2785d8 100644 --- a/internal/repo/store_test.go +++ b/internal/repo/store_test.go @@ -59,8 +59,8 @@ func TestPreferences(t *testing.T) { if err != nil { t.Fatalf("GetOrCreatePreferences() failed: %v", err) } - if prefs.DefaultMediaType != domain.MediaTypeVideoAudio { - t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType) + if prefs.DefaultMediaType != domain.MediaTypeAsk { + t.Errorf("default media type = %v, want Ask", prefs.DefaultMediaType) } prefs.DefaultMediaType = domain.MediaTypeAudio