From cd27b4d28dece138512e56058664bfc8c3d57eb0 Mon Sep 17 00:00:00 2001 From: db123 Date: Thu, 25 Jun 2026 15:07:31 +0330 Subject: [PATCH] feat: add thumbnail preview, audio-only detection, /download command, tests, and bugfixes - Add thumbnail preview with metadata (title, uploader, duration) before download - Auto-detect audio-only sites (Spotify, SoundCloud, etc.) and skip type selection - Add /download command for feature parity with UI button - Fix yt-dlp defer bug: StatusFailed was overwritten to StatusCompleted - Fix handleURLInput using editText with msgID=0 (now uses sendWithKB) - Fix filename detection: use job ID prefix for reliable file finding - Fix double answerCb between handleBackStep and handleDownloadPrompt - Fix runDownload unused msgID parameter - Fix context for download goroutine (use context.Background) - Remove unused startWebhook h parameter - Remove dead buildToggleKeyboard function - Add formatBytes, formatDuration, formatLabelForDisplay tests - Add IsAudioOnlyContent, MediaType.String tests - Add repo integration tests (users, preferences, quotas, history, delete) - Add cmd/bot tests (parseAllowedUsers, env helpers) - Add edge case tests for progress parser - Add zero/negative safety guard in formatDuration - Add .gitea, TODO.md, opencode.json to gitignore --- .gitignore | 2 + README.md | 15 ++- cmd/bot/main.go | 2 +- cmd/bot/main_test.go | 55 ++++++++ cmd/bot/webhook.go | 4 +- internal/dl/progress_test.go | 49 +++++++ internal/dl/ytdlp.go | 11 +- internal/domain/types.go | 12 ++ internal/domain/types_test.go | 90 +++++++++++++ internal/handler/download.go | 116 ++++++++++++++--- internal/handler/handler.go | 3 + internal/handler/handler_test.go | 84 ++++++++++++ internal/handler/keyboard.go | 34 ----- internal/handler/playlist.go | 4 +- internal/repo/store_test.go | 214 +++++++++++++++++++++++++++++++ 15 files changed, 631 insertions(+), 64 deletions(-) create mode 100644 cmd/bot/main_test.go create mode 100644 internal/domain/types_test.go create mode 100644 internal/handler/handler_test.go create mode 100644 internal/repo/store_test.go diff --git a/.gitignore b/.gitignore index 6d8d304..1e5f8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ .env .DS_Store tmp/ +TODO.md +opencode.json diff --git a/README.md b/README.md index 38db0ec..9802520 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # uptodownbot -Telegram bot for downloading media from supported sites using yt-dlp. +Telegram bot for downloading media from any site yt-dlp supports. ## Features -- Download audio, video, or combined audio+video from any site yt-dlp supports +- Download audio, video, or combined audio+video from YouTube, Spotify, SoundCloud, Vimeo, and hundreds more +- 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: media type, quality, language, container -- Playlist support with paginated entry selection (10 per page) -- Download progress updates (every 5%) -- Cancel downloads at any time +- Playlist support with paginated entry selection (10 per page, select all, confirm with count) +- Download progress updates (every 5% with speed and ETA) +- Cancel downloads at any time (terminates yt-dlp process) - Per-user quota system (daily, weekly, monthly) - User preferences (default quality, container, language) - Download history @@ -21,11 +23,12 @@ Telegram bot for downloading media from supported sites using yt-dlp. ## Commands - `/start` - Show main menu +- `/download` - Prompt for a download URL - `/settings` - Open settings - `/help` - Show help text - `/history` - View download history -Just send a URL to start downloading. +You can also send a URL directly to start downloading immediately. ## Configuration diff --git a/cmd/bot/main.go b/cmd/bot/main.go index ef8e063..d389191 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -74,7 +74,7 @@ func main() { defer cancel() if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" { - startWebhook(ctx, b, h, url) + startWebhook(ctx, b, url) } else { if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil { slog.Warn("failed to delete webhook", "error", err) diff --git a/cmd/bot/main_test.go b/cmd/bot/main_test.go new file mode 100644 index 0000000..d632826 --- /dev/null +++ b/cmd/bot/main_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "testing" +) + +func TestParseAllowedUsers(t *testing.T) { + tests := []struct { + input string + want map[int64]bool + }{ + {"", map[int64]bool{}}, + {"12345", map[int64]bool{12345: true}}, + {"12345,67890", map[int64]bool{12345: true, 67890: true}}, + {" 12345 , 67890 ", map[int64]bool{12345: true, 67890: true}}, + {"12345,", map[int64]bool{12345: true}}, + {",12345", map[int64]bool{12345: true}}, + {"invalid", map[int64]bool{}}, + } + for _, tt := range tests { + got := parseAllowedUsers(tt.input) + if len(got) != len(tt.want) { + t.Errorf("parseAllowedUsers(%q) = %v, want %v", tt.input, got, tt.want) + continue + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("parseAllowedUsers(%q)[%d] = %v, want %v", tt.input, k, got[k], v) + } + } + } +} + +func TestGetEnvDefault(t *testing.T) { + t.Setenv("TEST_EXISTS", "hello") + if got := getEnvDefault("TEST_EXISTS", "fallback"); got != "hello" { + t.Errorf("getEnvDefault() = %q, want %q", got, "hello") + } + if got := getEnvDefault("TEST_MISSING", "fallback"); got != "fallback" { + t.Errorf("getEnvDefault() = %q, want %q", got, "fallback") + } +} + +func TestGetEnvIntDefault(t *testing.T) { + t.Setenv("TEST_INT", "42") + if got := getEnvIntDefault("TEST_INT", 0); got != 42 { + t.Errorf("getEnvIntDefault() = %d, want %d", got, 42) + } + if got := getEnvIntDefault("TEST_MISSING", 99); got != 99 { + t.Errorf("getEnvIntDefault() = %d, want %d", got, 99) + } + if got := getEnvIntDefault("TEST_INT", 0); got != 42 { + t.Errorf("getEnvIntDefault() cached = %d, want %d", got, 42) + } +} diff --git a/cmd/bot/webhook.go b/cmd/bot/webhook.go index 7aa58f0..b232a76 100644 --- a/cmd/bot/webhook.go +++ b/cmd/bot/webhook.go @@ -7,11 +7,9 @@ import ( "os" tgbot "github.com/go-telegram/bot" - - "uptodownBot/internal/handler" ) -func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhookURL string) { +func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) { if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil { slog.Warn("failed to delete webhook", "error", err) } diff --git a/internal/dl/progress_test.go b/internal/dl/progress_test.go index 44b968d..f1355bd 100644 --- a/internal/dl/progress_test.go +++ b/internal/dl/progress_test.go @@ -46,6 +46,35 @@ func TestParseProgressLine(t *testing.T) { line: "some random log line", hasRes: false, }, + { + line: "[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", + pct: 0.0, + speed: "0.00B/s", + eta: "00:00", + hasRes: true, + }, + { + line: "[download] 0.0% of 1.00KiB at 100.00KiB/s ETA 00:01", + pct: 0.0, + speed: "100.00KiB/s", + eta: "00:01", + hasRes: true, + }, + { + line: "[download] 50.5% of 2.00MiB at 500.00KiB/s ETA 00:02", + pct: 50.5, + speed: "500.00KiB/s", + eta: "00:02", + hasRes: true, + }, + { + line: "[download] 1.2% of unknown size at 0.00B/s ETA Unknown", + hasRes: false, + }, + { + line: "[Merge] Merging video and audio...", + hasRes: false, + }, } for _, tt := range tests { got := parseProgressLine(tt.line) @@ -70,3 +99,23 @@ func TestParseProgressLine(t *testing.T) { } } } + +func TestParseProgressLineEdgeCases(t *testing.T) { + tests := []struct { + line string + name string + hasRes bool + }{ + {"[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", "multiple spaces", true}, + {"[download] 100.0% of 1.00GiB at 999.99MiB/s ETA 00:00", "high speed", true}, + {"[download] 100.0% of 999.99TiB at 1.00TiB/s ETA 00:00", "large numbers", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseProgressLine(tt.line) + if tt.hasRes && got == nil { + t.Errorf("parseProgressLine(%q) = nil, want result", tt.line) + } + }) + } +} diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go index 87c27e5..c696e0b 100644 --- a/internal/dl/ytdlp.go +++ b/internal/dl/ytdlp.go @@ -49,6 +49,8 @@ type ytdlpFormat struct { type ytdlpInfo struct { Title string `json:"title"` + Uploader string `json:"uploader"` + Thumbnail string `json:"thumbnail"` Duration float64 `json:"duration"` IsLive bool `json:"is_live"` Formats []ytdlpFormat `json:"formats"` @@ -95,6 +97,8 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) { mi := &domain.MediaInfo{ URL: url, Title: info.Title, + Uploader: info.Uploader, + Thumbnail: info.Thumbnail, Duration: info.Duration, IsPlaylist: info.Playlist != "", } @@ -146,6 +150,8 @@ func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) { mi := &domain.MediaInfo{ URL: url, Title: info.Title, + Uploader: info.Uploader, + Thumbnail: info.Thumbnail, IsPlaylist: true, PlaylistCount: info.PlaylistCount, PlaylistEntries: c.parsePlaylistEntries(info.Entries), @@ -226,8 +232,8 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c } } - // Output template - outputPath := filepath.Join(downloadDir, "%(id)s.%(ext)s") + // Output template with job ID prefix for reliable file finding + outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s") args = append(args, "--output", outputPath) // Progress reporting @@ -267,7 +273,6 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c go func() { defer func() { if job.Status != domain.StatusCancelled { - job.Status = domain.StatusCompleted updates <- job } close(updates) diff --git a/internal/domain/types.go b/internal/domain/types.go index a507519..7784540 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -45,6 +45,8 @@ type Format struct { type MediaInfo struct { URL string Title string + Uploader string + Thumbnail string Duration float64 Formats []Format Languages []string @@ -54,6 +56,16 @@ type MediaInfo struct { EstimatedSize int64 } +// IsAudioOnlyContent returns true when no format carries a video stream. +func (m *MediaInfo) IsAudioOnlyContent() bool { + for _, f := range m.Formats { + if f.HasVideo { + return false + } + } + return len(m.Formats) > 0 +} + // PlaylistEntry represents a single entry in a playlist. type PlaylistEntry struct { ID string diff --git a/internal/domain/types_test.go b/internal/domain/types_test.go new file mode 100644 index 0000000..60091cb --- /dev/null +++ b/internal/domain/types_test.go @@ -0,0 +1,90 @@ +package domain + +import "testing" + +func TestMediaTypeString(t *testing.T) { + tests := []struct { + mt MediaType + want string + }{ + {MediaTypeAudio, "audio"}, + {MediaTypeVideo, "video"}, + {MediaTypeVideoAudio, "video_audio"}, + {MediaType(0), "unknown"}, + {MediaType(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.mt.String(); got != tt.want { + t.Errorf("MediaType(%d).String() = %q, want %q", tt.mt, got, tt.want) + } + } +} + +func TestIsAudioOnlyContent(t *testing.T) { + tests := []struct { + name string + media *MediaInfo + want bool + }{ + { + name: "empty formats returns false", + media: &MediaInfo{Formats: nil}, + want: false, + }, + { + name: "all audio formats returns true", + media: &MediaInfo{ + Formats: []Format{ + {ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true}, + {ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true}, + }, + }, + want: true, + }, + { + name: "mixed formats returns false", + media: &MediaInfo{ + Formats: []Format{ + {ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true}, + {ID: "2", HasVideo: true, HasAudio: true}, + }, + }, + want: false, + }, + { + name: "single format with no video returns true", + media: &MediaInfo{ + Formats: []Format{ + {ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true}, + }, + }, + want: true, + }, + { + name: "format with both video and audio returns false", + media: &MediaInfo{ + Formats: []Format{ + {ID: "1", HasVideo: true, HasAudio: true}, + }, + }, + want: false, + }, + { + name: "only audio and no-video-no-audio formats returns true", + media: &MediaInfo{ + Formats: []Format{ + {ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true}, + {ID: "2", HasVideo: false, HasAudio: false}, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.media.IsAudioOnlyContent(); got != tt.want { + t.Errorf("IsAudioOnlyContent() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/handler/download.go b/internal/handler/download.go index e02e40a..5264c66 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "log/slog" + "math" "os" "path/filepath" "strings" + "time" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" @@ -111,9 +113,36 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, Step: 0, } h.setUserStepState(chatID, ss) - h.editText(ctx, chatID, 0, fmt.Sprintf("URL: %s\nTitle: %s\n\nSelect media type:", url, info.Title), &models.InlineKeyboardMarkup{ - InlineKeyboard: mediaTypeKeyboard().InlineKeyboard, - }) + + // Show thumbnail and metadata preview + h.sendMediaPreview(ctx, chatID, info) + + if info.IsAudioOnlyContent() { + // Audio-only sites: skip type selection, go straight to quality + ss.MediaType = domain.MediaTypeAudio + ss.Step = 1 + filtered := dl.FilterFormatsByType(info.Formats, domain.MediaTypeAudio) + if len(filtered) == 0 { + h.sendText(ctx, chatID, "No audio formats available for this URL.") + return + } + deduped := dl.DeduplicateResolutions(filtered) + ss.FormatIDs = make([]string, len(deduped)) + labels := make([]string, len(deduped)) + for i, f := range deduped { + ss.FormatIDs[i] = f.ID + labels[i] = formatLabelForDisplay(f) + } + ss.FormatIDs = append([]string{"best"}, ss.FormatIDs...) + labels = append([]string{"Best"}, labels...) + h.setUserStepState(chatID, ss) + kb := formatKeyboard("quality_", labels) + h.sendWithKB(ctx, chatID, "Select audio quality:", kb) + return + } + + kb := mediaTypeKeyboard() + h.sendWithKB(ctx, chatID, fmt.Sprintf("Title: %s\n\nSelect media type:", info.Title), kb) } // handleTypeSelection processes the media type choice. @@ -253,11 +282,11 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms h.clearStepState(chatID) // Start download in background - go h.runDownload(ctx, job, msgID) + go h.runDownload(context.Background(), job) } // runDownload manages the download lifecycle, progress updates, and completion. -func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgID int) { +func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) { // Show initial progress message kb := progressKeyboard(0, "", "") msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ @@ -329,21 +358,20 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgI h.mu.Unlock() if job.Status == domain.StatusCompleted { - // Find the downloaded file - files, err := filepath.Glob(filepath.Join(h.downloadDir, "*")) - if err == nil { - for _, f := range files { - fileInfo, err := os.Stat(f) - if err != nil { - continue - } + // Find the downloaded file using job ID prefix + pattern := filepath.Join(h.downloadDir, job.ID+"_*") + files, err := filepath.Glob(pattern) + if err == nil && len(files) > 0 { + f := files[0] // take the first match + fileInfo, err := os.Stat(f) + if err == nil { if fileInfo.Size() > h.maxFileSize { h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil) os.Remove(f) return } - // Check file size quota after download + if !job.QuotaApplied && job.FileSize > 0 { job.QuotaApplied = true h.applyQuota(job.UserID, job.FileSize) @@ -353,7 +381,6 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgI h.sendDocument(ctx, job.ChatID, f, job.Title) os.Remove(f) - // Save to history _ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{ URL: job.URL, Title: job.Title, @@ -425,6 +452,12 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c h.clearStepState(chatID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) case 0: + if ss.MediaInfo != nil && ss.MediaInfo.IsAudioOnlyContent() { + // Audio-only: go back to URL input directly + h.clearStepState(chatID) + h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) + return + } h.editText(ctx, chatID, msgID, "Select media type:", &models.InlineKeyboardMarkup{ InlineKeyboard: mediaTypeKeyboard().InlineKeyboard, }) @@ -450,9 +483,60 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c } } +// handleDownloadCommand handles the /download command, prompting for a URL. +func (h *Handler) handleDownloadCommand(ctx context.Context, msg *models.Message, userID int64) { + h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingURL, + "Send me the URL to download:", nil) +} + // handleDownloadPrompt shows the download prompt (asks for URL). func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) { - defer h.answerCb(ctx, cbID) h.promptForInput(ctx, chatID, msgID, userID, PendingURL, "Send me the URL to download:", nil) } + +// sendMediaPreview sends a thumbnail and metadata info before the selection flow. +func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) { + caption := info.Title + if info.Uploader != "" || info.Duration > 0 { + caption += "\n" + if info.Uploader != "" { + caption += fmt.Sprintf("Channel: %s", info.Uploader) + } + if info.Duration > 0 { + if info.Uploader != "" { + caption += " | " + } + caption += fmt.Sprintf("Duration: %s", formatDuration(info.Duration)) + } + } + + if info.Thumbnail != "" { + photo := &models.InputFileString{Data: info.Thumbnail} + _, err := h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{ + ChatID: chatID, + Photo: photo, + Caption: caption, + }) + if err == nil { + return + } + slog.Warn("send thumbnail failed, falling back to text", "error", err) + } + + h.sendText(ctx, chatID, caption) +} + +func formatDuration(seconds float64) string { + if seconds <= 0 { + return "0:00" + } + d := time.Duration(seconds) * time.Second + h := int(d.Hours()) + m := int(math.Mod(d.Minutes(), 60)) + s := int(math.Mod(d.Seconds(), 60)) + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%d:%02d", m, s) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 578bffd..ee211b1 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -182,6 +182,8 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, userID h.handleHelp(ctx, msg) case "/history": h.handleHistory(ctx, msg, userID) + case "/download": + h.handleDownloadCommand(ctx, msg, userID) default: h.sendText(ctx, msg.Chat.ID, "Unknown command") } @@ -222,6 +224,7 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) h.backToMenu(ctx, chatID, msgID, cb.ID, userID) case data == "download": h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID) + h.answerCb(ctx, cb.ID) case data == "settings": h.settingsCallback(ctx, chatID, msgID, cb.ID, userID) case data == "help": diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 0000000..0e8f332 --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,84 @@ +package handler + +import ( + "testing" + + "uptodownBot/internal/domain" +) + +func TestFormatBytes(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {0, "0B"}, + {1, "1B"}, + {1023, "1023B"}, + {1024, "1.0KB"}, + {1536, "1.5KB"}, + {1048576, "1.0MB"}, + {1073741824, "1.0GB"}, + {1099511627776, "1.0TB"}, + } + for _, tt := range tests { + got := formatBytes(tt.input) + if got != tt.want { + t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + input float64 + want string + }{ + {0, "0:00"}, + {5, "0:05"}, + {60, "1:00"}, + {65, "1:05"}, + {3600, "1:00:00"}, + {3661, "1:01:01"}, + {86400, "24:00:00"}, + } + for _, tt := range tests { + got := formatDuration(tt.input) + if got != tt.want { + t.Errorf("formatDuration(%f) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestFormatLabelForDisplay(t *testing.T) { + tests := []struct { + f domain.Format + want string + }{ + { + f: domain.Format{Resolution: "1920x1080", Filesize: 1048576}, + want: "1920x1080 (1.0MB)", + }, + { + f: domain.Format{Resolution: "1920x1080"}, + want: "1920x1080", + }, + { + f: domain.Format{Bitrate: "128k", Filesize: 1048576}, + want: "128k (1.0MB)", + }, + { + f: domain.Format{Bitrate: "128k"}, + want: "128k", + }, + { + f: domain.Format{ID: "137"}, + want: "137", + }, + } + for _, tt := range tests { + got := formatLabelForDisplay(tt.f) + if got != tt.want { + t.Errorf("formatLabelForDisplay(%+v) = %q, want %q", tt.f, got, tt.want) + } + } +} diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go index d57dffd..adfaf89 100644 --- a/internal/handler/keyboard.go +++ b/internal/handler/keyboard.go @@ -128,40 +128,6 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku } } -// buildToggleKeyboard creates an on/off picker for a boolean setting. -// Matches worktimeBot's pattern with "> " prefix for the current selection. -func buildToggleKeyboard(userID int64, setting, label string, st *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { - current := false - switch setting { - case "quality": - current = st.DefaultQuality == "best" - case "container": - current = st.DefaultContainer == "mp4" - } - options := []struct { - label string - data string - value bool - }{ - {"Enabled", setting + "_enable", true}, - {"Disabled", setting + "_disable", false}, - } - rows := [][]models.InlineKeyboardButton{} - for _, opt := range options { - l := opt.label - if opt.value == current { - l = "> " + l - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: l, CallbackData: opt.data}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back to Settings", CallbackData: "back_settings"}, - }) - return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - // playlistNavKeyboard builds the paginated navigation for playlist selection. func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup { totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage diff --git a/internal/handler/playlist.go b/internal/handler/playlist.go index c05af3f..3824f3e 100644 --- a/internal/handler/playlist.go +++ b/internal/handler/playlist.go @@ -24,6 +24,8 @@ func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64 } h.setUserStepState(chatID, ss) + h.sendMediaPreview(ctx, chatID, info) + kb := playlistNavKeyboard(state, 0) h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb) } @@ -169,7 +171,7 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID } h.setActiveJob(userID, job) - h.runDownload(ctx, job, 0) + h.runDownload(context.Background(), job) // Wait for active job to complete for { diff --git a/internal/repo/store_test.go b/internal/repo/store_test.go new file mode 100644 index 0000000..ee5c5bc --- /dev/null +++ b/internal/repo/store_test.go @@ -0,0 +1,214 @@ +package repo + +import ( + "testing" + + "uptodownBot/internal/domain" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := NewStore("file::memory:?cache=shared") + if err != nil { + t.Fatalf("NewStore() failed: %v", err) + } + return s +} + +func TestGetOrCreateUser(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + id, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() failed: %v", err) + } + if id == 0 { + t.Fatal("GetOrCreateUser() returned 0") + } + + // Second call should return same ID + id2, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() second call failed: %v", err) + } + if id != id2 { + t.Errorf("GetOrCreateUser() returned different IDs: %d vs %d", id, id2) + } + + // Different chat_id returns different user ID + id3, err := s.GetOrCreateUser(67890) + if err != nil { + t.Fatalf("GetOrCreateUser() third call failed: %v", err) + } + if id3 == id { + t.Error("GetOrCreateUser() should return different IDs for different chat_ids") + } +} + +func TestPreferences(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + userID, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() failed: %v", err) + } + + prefs, err := s.GetOrCreatePreferences(userID) + if err != nil { + t.Fatalf("GetOrCreatePreferences() failed: %v", err) + } + if prefs.DefaultMediaType != domain.MediaTypeVideoAudio { + t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType) + } + + prefs.DefaultMediaType = domain.MediaTypeAudio + prefs.DefaultQuality = "best" + prefs.DefaultContainer = "mp3" + prefs.DefaultLanguage = "en" + if err := s.UpdatePreferences(userID, prefs); err != nil { + t.Fatalf("UpdatePreferences() failed: %v", err) + } + + got, err := s.GetOrCreatePreferences(userID) + if err != nil { + t.Fatalf("GetOrCreatePreferences() after update failed: %v", err) + } + if got.DefaultMediaType != domain.MediaTypeAudio { + t.Errorf("media type = %v, want Audio", got.DefaultMediaType) + } + if got.DefaultQuality != "best" { + t.Errorf("quality = %q, want best", got.DefaultQuality) + } + if got.DefaultContainer != "mp3" { + t.Errorf("container = %q, want mp3", got.DefaultContainer) + } + if got.DefaultLanguage != "en" { + t.Errorf("language = %q, want en", got.DefaultLanguage) + } +} + +func TestQuotas(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + userID, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() failed: %v", err) + } + + q, err := s.GetOrCreateQuotas(userID) + if err != nil { + t.Fatalf("GetOrCreateQuotas() failed: %v", err) + } + if q.DailyLimitMB != domain.DefaultDailyQuotaMB { + t.Errorf("daily limit = %d, want %d", q.DailyLimitMB, domain.DefaultDailyQuotaMB) + } + + q.DailyUsedMB = 50 + q.WeeklyUsedMB = 200 + q.MonthlyUsedMB = 500 + if err := s.UpdateQuotas(userID, q); err != nil { + t.Fatalf("UpdateQuotas() failed: %v", err) + } + + got, err := s.GetOrCreateQuotas(userID) + if err != nil { + t.Fatalf("GetOrCreateQuotas() after update failed: %v", err) + } + if got.DailyUsedMB != 50 { + t.Errorf("daily used = %d, want 50", got.DailyUsedMB) + } +} + +func TestHistory(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + userID, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() failed: %v", err) + } + + rec := &domain.DownloadRecord{ + URL: "https://example.com/video", + Title: "Test Video", + MediaType: "video", + FormatID: "137", + Container: "mp4", + FileSize: 1048576, + Status: "completed", + } + if err := s.AddHistory(userID, rec); err != nil { + t.Fatalf("AddHistory() failed: %v", err) + } + + records, err := s.GetHistory(userID, 10, 0) + if err != nil { + t.Fatalf("GetHistory() failed: %v", err) + } + if len(records) != 1 { + t.Fatalf("GetHistory() returned %d records, want 1", len(records)) + } + if records[0].URL != rec.URL { + t.Errorf("record URL = %q, want %q", records[0].URL, rec.URL) + } + + count, err := s.GetHistoryCount(userID) + if err != nil { + t.Fatalf("GetHistoryCount() failed: %v", err) + } + if count != 1 { + t.Errorf("history count = %d, want 1", count) + } + + // Test pagination + records2, err := s.GetHistory(userID, 10, 1) + if err != nil { + t.Fatalf("GetHistory() with offset failed: %v", err) + } + if len(records2) != 0 { + t.Errorf("GetHistory() with offset 1 returned %d records, want 0", len(records2)) + } +} + +func TestDeleteUserData(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + userID, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() failed: %v", err) + } + + _, err = s.GetOrCreatePreferences(userID) + if err != nil { + t.Fatalf("GetOrCreatePreferences() failed: %v", err) + } + _, err = s.GetOrCreateQuotas(userID) + if err != nil { + t.Fatalf("GetOrCreateQuotas() failed: %v", err) + } + s.AddHistory(userID, &domain.DownloadRecord{ + URL: "https://example.com", Title: "Test", Status: "completed", + }) + + if err := s.DeleteUserData(userID); err != nil { + t.Fatalf("DeleteUserData() failed: %v", err) + } + + // Verify user no longer exists + _, err = s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() after delete failed: %v", err) + } + + count, err := s.GetHistoryCount(userID) + if err != nil { + t.Fatalf("GetHistoryCount() after delete failed: %v", err) + } + if count != 0 { + t.Errorf("history count after delete = %d, want 0", count) + } +}