feat: add auto-select from preferences and type picker
All checks were successful
CI / build (push) Successful in 52s

- 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
This commit is contained in:
2026-06-28 11:28:53 +03:30
parent 6bbc87a7c8
commit b11b5649a5
12 changed files with 578 additions and 18 deletions

View File

@@ -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 - DRM fallback: automatically searches YouTube when Spotify/DRM-protected content is detected
- Thumbnail preview with metadata (title, uploader, duration) before download - 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 - 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) - Download progress updates (with speed and ETA)
- Cancel downloads at any time (terminates yt-dlp process) - Cancel downloads at any time (terminates yt-dlp process)
- Per-user quota system (daily, weekly, monthly; 0 = unlimited) - 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 - Download history with pagination
- Configurable file size limit (default 50 MB) - 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 - Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
- Webhook and polling modes - Webhook and polling modes
- SQLite database for persistence - SQLite database for persistence
@@ -52,6 +53,17 @@ Copy `.env.example` to `.env` and configure:
| `BOT_TLS_CERT` | empty | TLS certificate path | | `BOT_TLS_CERT` | empty | TLS certificate path |
| `BOT_TLS_KEY` | empty | TLS key 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 ## Running
### Polling mode (default) ### Polling mode (default)

View File

@@ -15,6 +15,7 @@ services:
- TZ=${TZ} - TZ=${TZ}
volumes: volumes:
- uptodown_data:/data - uptodown_data:/data
- ./cookies.txt:/cookies.txt:ro # Optional: cookies file for yt-dlp auth
volumes: volumes:
uptodown_data: uptodown_data:

View File

@@ -250,6 +250,11 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s") outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
args = append(args, "--output", outputPath) 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 // --progress forces progress output in non-TTY (pipe) environments
// --newline uses \n instead of \r so bufio.Scanner can read line-by-line // --newline uses \n instead of \r so bufio.Scanner can read line-by-line
args = append(args, "--progress", "--newline") args = append(args, "--progress", "--newline")

View File

@@ -6,13 +6,16 @@ import "os/exec"
type MediaType int type MediaType int
const ( const (
MediaTypeAudio MediaType = iota + 1 MediaTypeAsk MediaType = iota
MediaTypeAudio
MediaTypeVideo MediaTypeVideo
MediaTypeVideoAudio MediaTypeVideoAudio
) )
func (m MediaType) String() string { func (m MediaType) String() string {
switch m { switch m {
case MediaTypeAsk:
return "ask"
case MediaTypeAudio: case MediaTypeAudio:
return "audio" return "audio"
case MediaTypeVideo: case MediaTypeVideo:

View File

@@ -10,7 +10,7 @@ func TestMediaTypeString(t *testing.T) {
{MediaTypeAudio, "audio"}, {MediaTypeAudio, "audio"},
{MediaTypeVideo, "video"}, {MediaTypeVideo, "video"},
{MediaTypeVideoAudio, "video_audio"}, {MediaTypeVideoAudio, "video_audio"},
{MediaType(0), "unknown"}, {MediaTypeAsk, "ask"},
{MediaType(99), "unknown"}, {MediaType(99), "unknown"},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -24,6 +24,7 @@ type stepState struct {
URL string URL string
MediaInfo *domain.MediaInfo MediaInfo *domain.MediaInfo
Step int Step int
SelectedType string // "video", "audio", "ask", or "" when not yet chosen
MainFormatID string MainFormatID string
MainFormat *domain.Format MainFormat *domain.Format
SecondaryID string SecondaryID string
@@ -158,6 +159,34 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
h.sendMediaPreview(ctx, chatID, info) 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{ ss := &stepState{
URL: url, URL: url,
MediaInfo: info, MediaInfo: info,
@@ -165,14 +194,74 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
CreatedAt: time.Now(), 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) 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" { if id == "best" {
labels[i] = "Best" labels[i] = "Best"
} else { } else {
f := findFormatByID(info.Formats, id) f := findFormatByID(ss.MediaInfo.Formats, id)
if f != nil { if f != nil {
labels[i] = formatLabelForDisplay(*f) labels[i] = formatLabelForDisplay(*f)
} else { } else {
@@ -180,8 +269,8 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
} }
} }
} }
kb := formatKeyboard("format_", labels, ss.FormatIDs) kb := formatKeyboard("format_", labels, ids)
h.sendWithKB(ctx, chatID, "Select format:", kb) h.editText(ctx, chatID, msgID, "Select format:", &kb)
} }
// handleFormatSelection processes a format pick from the unified list. // 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 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-- ss.Step--
switch ss.Step { switch ss.Step {
case -1: case -1:
h.clearStepState(chatID) h.clearStepState(chatID)
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
case 0: case 0:
ss.FormatIDs, _ = buildFormatList(ss.MediaInfo.Formats) ss.FormatIDs, _ = buildFilteredFormatList(ss.MediaInfo.Formats, "")
labels := make([]string, len(ss.FormatIDs)) labels := make([]string, len(ss.FormatIDs))
for i, id := range ss.FormatIDs { for i, id := range ss.FormatIDs {
if id == "best" { if id == "best" {
@@ -685,6 +783,162 @@ func buildFormatList(formats []domain.Format) ([]string, error) {
return ids, nil 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. // findFormatByID returns the format with the given ID from the list, or nil.
func findFormatByID(formats []domain.Format, id string) *domain.Format { func findFormatByID(formats []domain.Format, id string) *domain.Format {
for i := range formats { for i := range formats {
@@ -716,6 +970,13 @@ func formatLabelForDisplay(f domain.Format) string {
} }
return label 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 != "" { if f.Bitrate != "" {
label := f.Bitrate label := f.Bitrate
if f.Filesize > 0 { if f.Filesize > 0 {
@@ -723,7 +984,11 @@ func formatLabelForDisplay(f domain.Format) string {
} }
return label 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. // sendMediaPreview sends a thumbnail and metadata info before the selection flow.

View File

@@ -273,6 +273,8 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
defer h.answerCb(ctx, cbID) defer h.answerCb(ctx, cbID)
switch { switch {
case strings.HasPrefix(data, "type_"):
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "format_"): case strings.HasPrefix(data, "format_"):
h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:]) h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:])
case strings.HasPrefix(data, "secondary_"): case strings.HasPrefix(data, "secondary_"):

View File

@@ -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) { func TestFormatLabelForDisplay(t *testing.T) {
tests := []struct { tests := []struct {
f domain.Format f domain.Format

View File

@@ -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. // deleteConfirmKeyboard builds the confirmation prompt for account deletion.
func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup { func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{ return models.InlineKeyboardMarkup{

View File

@@ -25,11 +25,22 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
langLabel = "Default" langLabel = "Default"
} }
mediaTypeLabel := "Ask"
switch prefs.DefaultMediaType {
case domain.MediaTypeAudio:
mediaTypeLabel = "Audio"
case domain.MediaTypeVideo:
mediaTypeLabel = "Video"
}
rows := [][]models.InlineKeyboardButton{ 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("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: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}},
} }
@@ -62,6 +73,9 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
} }
switch setting { switch setting {
case "mediatype":
text, kb := h.buildMediaTypePicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "quality": case "quality":
text, kb := h.buildQualityAudioVideoPicker(prefs) text, kb := h.buildQualityAudioVideoPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) 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} 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) { func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
parts := strings.SplitN(setting, "_", 2) parts := strings.SplitN(setting, "_", 2)
if len(parts) < 2 { if len(parts) < 2 {
@@ -170,6 +209,16 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
changed := false changed := false
switch action { 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": case "audioquality":
prefs.DefaultAudioFormat = value prefs.DefaultAudioFormat = value
changed = true changed = true

View File

@@ -65,7 +65,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (int64, error) {
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) { func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
if _, err := s.db.Exec( 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, userID,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -90,14 +90,18 @@ func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
p.DefaultMediaType = domain.MediaTypeAudio p.DefaultMediaType = domain.MediaTypeAudio
case "video": case "video":
p.DefaultMediaType = domain.MediaTypeVideo p.DefaultMediaType = domain.MediaTypeVideo
case "ask":
p.DefaultMediaType = domain.MediaTypeAsk
default: default:
p.DefaultMediaType = domain.MediaTypeVideoAudio p.DefaultMediaType = domain.MediaTypeAsk
} }
return &p, nil return &p, nil
} }
func mediaTypeToString(mt domain.MediaType) string { func mediaTypeToString(mt domain.MediaType) string {
switch mt { switch mt {
case domain.MediaTypeAsk:
return "ask"
case domain.MediaTypeAudio: case domain.MediaTypeAudio:
return "audio" return "audio"
case domain.MediaTypeVideo: case domain.MediaTypeVideo:

View File

@@ -59,8 +59,8 @@ func TestPreferences(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetOrCreatePreferences() failed: %v", err) t.Fatalf("GetOrCreatePreferences() failed: %v", err)
} }
if prefs.DefaultMediaType != domain.MediaTypeVideoAudio { if prefs.DefaultMediaType != domain.MediaTypeAsk {
t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType) t.Errorf("default media type = %v, want Ask", prefs.DefaultMediaType)
} }
prefs.DefaultMediaType = domain.MediaTypeAudio prefs.DefaultMediaType = domain.MediaTypeAudio