From 37e7f918d0511fd854dcb10ed164dfe6896e96b6 Mon Sep 17 00:00:00 2001 From: db123 Date: Thu, 25 Jun 2026 23:41:26 +0330 Subject: [PATCH] refactor: remove container selection, extract helpers, add logging and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove container picker UI, keyboard, settings, and ContainerOptions entirely — yt-dlp handles container format automatically - Remove Container field from DownloadJob struct and related references - Extract buildFormatList helper to deduplicate format list building between handleURLInput and handleBackStep - Break runDownload into trackDownloadProgress + handleDownloadCompletion - Add HTTP timeout (30s) to downloadFile helper - Log applyQuota and AddHistory errors instead of discarding - Log nil stepState warnings in all handler entry points - Include job.StderrLog in download failure messages - Add tests for buildFormatList, findFormatByID, determineMediaType, and buildFormatString - Update README with search, DRM fallback, and quota feature docs - Remove unused strings import from keyboard.go --- README.md | 14 +- internal/dl/ytdlp.go | 29 +--- internal/dl/ytdlp_test.go | 31 ---- internal/domain/types.go | 1 - internal/handler/download.go | 251 ++++++++++++++++--------------- internal/handler/handler.go | 9 +- internal/handler/handler_test.go | 120 +++++++++++++++ internal/handler/keyboard.go | 20 --- internal/handler/playlist.go | 11 +- internal/handler/settings.go | 76 ---------- 10 files changed, 266 insertions(+), 296 deletions(-) diff --git a/README.md b/README.md index 16220da..80d333a 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,18 @@ Telegram bot for downloading media from any site yt-dlp supports. ## Features - Download audio, video, or combined audio+video from YouTube, Spotify, SoundCloud, Vimeo, and hundreds more +- Search support: search YouTube/YouTube Music by name when no URL is provided +- 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: media type, quality, language, container +- Multi-step format selection: quality, secondary format (audio/video combine), language - Playlist support with paginated entry selection (10 per page, select all, confirm with count) -- Download progress updates (every 5% with speed and ETA) +- Download progress updates (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 -- Configurable file size limit +- Per-user quota system (daily, weekly, monthly; 0 = unlimited) +- User preferences (default audio/video quality, language) +- Download history with pagination +- Configurable file size limit (default 50 MB) - Cookies support for age-restricted content - Proxy support via HTTP_PROXY/HTTPS_PROXY env vars - Webhook and polling modes diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go index da25a68..2ed2314 100644 --- a/internal/dl/ytdlp.go +++ b/internal/dl/ytdlp.go @@ -265,28 +265,17 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c formatID := job.SelectedFormat.ID var args []string - // Use explicit format string if set (video+audio combination) + // Use format string for the download — yt-dlp handles container choice if job.FormatString != "" { args = append(args, "-f", job.FormatString) - if job.Container != "" { - args = append(args, "--merge-output-format", job.Container) - } } else { - // Build format string for merge switch job.MediaType { case domain.MediaTypeAudio: args = append(args, "-f", formatID, "-x") - if job.Container != "" { - args = append(args, "--audio-format", job.Container) - } case domain.MediaTypeVideo: args = append(args, "-f", formatID) case domain.MediaTypeVideoAudio: - // Download best video + best audio that match args = append(args, "-f", formatID+"+bestaudio/best") - if job.Container != "" { - args = append(args, "--merge-output-format", job.Container) - } } } @@ -296,7 +285,7 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c // Progress reporting using progress-template for machine-readable output args = append(args, "--newline") - args = append(args, "--progress-template", "stderr:DLPROG|%(progress.percent)s|%(progress._speed_str)s|%(progress._eta_str)s") + args = append(args, "--progress-template", "stderr:DLPROG|%(progress.percent)s|%(progress.speed)s|%(progress.eta)s") // Language selection if specified if job.Language != "" { @@ -527,17 +516,3 @@ func formatLabel(f domain.Format) string { } return f.ID } - -// ContainerOptions returns the container format options for a given media type. -func ContainerOptions(mt domain.MediaType) []string { - switch mt { - case domain.MediaTypeAudio: - return []string{"mp3", "m4a", "opus"} - case domain.MediaTypeVideo: - return []string{"mp4", "mkv", "webm"} - case domain.MediaTypeVideoAudio: - return []string{"mp4", "mkv"} - default: - return []string{"mp4"} - } -} diff --git a/internal/dl/ytdlp_test.go b/internal/dl/ytdlp_test.go index ae3ad6d..82d0ed4 100644 --- a/internal/dl/ytdlp_test.go +++ b/internal/dl/ytdlp_test.go @@ -52,37 +52,6 @@ func TestDeduplicateResolutions(t *testing.T) { } } -func TestContainerOptions(t *testing.T) { - tests := []struct { - mt domain.MediaType - want []string - }{ - {domain.MediaTypeAudio, []string{"mp3", "m4a", "opus"}}, - {domain.MediaTypeVideo, []string{"mp4", "mkv", "webm"}}, - {domain.MediaTypeVideoAudio, []string{"mp4", "mkv"}}, - } - for _, tt := range tests { - got := ContainerOptions(tt.mt) - if len(got) != len(tt.want) { - t.Errorf("ContainerOptions(%v) = %v, want %v", tt.mt, got, tt.want) - continue - } - for i, c := range got { - if c != tt.want[i] { - t.Errorf("ContainerOptions(%v)[%d] = %s, want %s", tt.mt, i, c, tt.want[i]) - } - } - } -} - -func TestContainerOptionsZeroValue(t *testing.T) { - got := ContainerOptions(domain.MediaType(0)) - want := []string{"mp4"} - if len(got) != 1 || got[0] != want[0] { - t.Errorf("ContainerOptions(0) = %v, want %v", got, want) - } -} - func TestFormatLabel(t *testing.T) { tests := []struct { f domain.Format diff --git a/internal/domain/types.go b/internal/domain/types.go index f42fb6c..7668d39 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -118,7 +118,6 @@ type DownloadJob struct { MediaType MediaType SelectedFormat *Format FormatString string - Container string Language string Status DownloadStatus Progress float64 diff --git a/internal/handler/download.go b/internal/handler/download.go index a9ecf24..51c299c 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -28,7 +28,6 @@ type stepState struct { SecondaryID string SecondaryFmt *domain.Format Language string - Container string FormatIDs []string PlaylistState *domain.PlaylistState SearchState *domain.SearchState @@ -138,30 +137,21 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, h.sendMediaPreview(ctx, chatID, info) - // Build unified format list: video formats first, then audio - deduped := dl.DeduplicateResolutions(info.Formats) - var videoFmts, audioFmts []domain.Format - for _, f := range deduped { - if f.HasVideo { - videoFmts = append(videoFmts, f) + ss.FormatIDs, _ = buildFormatList(info.Formats) + h.setUserStepState(chatID, ss) + labels := make([]string, len(ss.FormatIDs)) + for i, id := range ss.FormatIDs { + if id == "best" { + labels[i] = "Best" } else { - audioFmts = append(audioFmts, f) + f := findFormatByID(info.Formats, id) + if f != nil { + labels[i] = formatLabelForDisplay(*f) + } else { + labels[i] = id + } } } - ss.FormatIDs = make([]string, 0, len(deduped)+1) - labels := make([]string, 0, len(deduped)+1) - ss.FormatIDs = append(ss.FormatIDs, "best") - labels = append(labels, "Best") - for _, f := range videoFmts { - ss.FormatIDs = append(ss.FormatIDs, f.ID) - labels = append(labels, formatLabelForDisplay(f)) - } - for _, f := range audioFmts { - ss.FormatIDs = append(ss.FormatIDs, f.ID) - labels = append(labels, formatLabelForDisplay(f)) - } - - h.setUserStepState(chatID, ss) kb := formatKeyboard("format_", labels, ss.FormatIDs) h.sendWithKB(ctx, chatID, "Select format:", kb) } @@ -170,6 +160,7 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) { ss := h.getUserStepState(chatID) if ss == nil { + slog.Warn("nil stepState in handleFormatSelection", "chat_id", chatID, "user_id", userID) return } ss.MainFormatID = formatID @@ -183,7 +174,6 @@ func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID h.advanceToLanguage(ctx, chatID, msgID, userID, ss) return } - for i := range ss.MediaInfo.Formats { f := &ss.MediaInfo.Formats[i] if f.ID == formatID { @@ -255,6 +245,7 @@ func (h *Handler) showSecondaryFormats(ctx context.Context, chatID int64, msgID func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) { ss := h.getUserStepState(chatID) if ss == nil { + slog.Warn("nil stepState in handleSecondarySelection", "chat_id", chatID, "user_id", userID) return } @@ -280,7 +271,7 @@ func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, ms h.advanceToLanguage(ctx, chatID, msgID, userID, ss) } -// advanceToLanguage moves to language selection, or skips to container if N/A. +// advanceToLanguage moves to language selection, or starts the download if only one language. func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) { ss.Step = 2 h.setUserStepState(chatID, ss) @@ -290,17 +281,7 @@ func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int h.editText(ctx, chatID, msgID, "Select language:", &kb) return } - h.advanceToContainer(ctx, chatID, msgID, userID, ss) -} - -// advanceToContainer shows the container picker. -func (h *Handler) advanceToContainer(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) { - ss.Step = 3 - h.setUserStepState(chatID, ss) - - containers := dl.ContainerOptions(ss.determineMediaType()) - kb := containerKeyboard(containers) - h.editText(ctx, chatID, msgID, "Select container format:", &kb) + h.startDownload(ctx, chatID, msgID, userID, ss) } func (ss *stepState) determineMediaType() domain.MediaType { @@ -327,20 +308,15 @@ func (ss *stepState) buildFormatString() string { func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) { ss := h.getUserStepState(chatID) if ss == nil { + slog.Warn("nil stepState in handleLanguageSelection", "chat_id", chatID, "user_id", userID) return } ss.Language = language - h.advanceToContainer(ctx, chatID, msgID, userID, ss) + h.startDownload(ctx, chatID, msgID, userID, ss) } -// handleContainerSelection processes the container choice and starts the download. -func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, msgID int, userID int64, container string) { - ss := h.getUserStepState(chatID) - if ss == nil { - return - } - ss.Container = container - +// startDownload creates the download job and begins the download process. +func (h *Handler) startDownload(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) { mediaType := ss.determineMediaType() formatString := ss.buildFormatString() @@ -368,7 +344,6 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms MediaType: mediaType, SelectedFormat: format, FormatString: formatString, - Container: container, Language: ss.Language, Status: domain.StatusDownloading, Title: ss.MediaInfo.Title, @@ -392,7 +367,6 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) { } }() - // Acquire semaphore slot or wait for cancellation select { case h.downloadSem <- struct{}{}: case <-ctx.Done(): @@ -414,21 +388,33 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) { updates, err := h.YtDlp.StartDownload(job, h.downloadDir) if err != nil { - h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", err.Error()), nil) + errMsg := err.Error() + if job.StderrLog != "" { + errMsg += ": " + job.StderrLog + } + h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", errMsg), nil) job.Status = domain.StatusFailed - h.activeJobsMu.Lock() - delete(h.activeJobs, job.UserID) - h.activeJobsMu.Unlock() + h.clearActiveJob(job.UserID) return } + h.trackDownloadProgress(ctx, job, updates) + + h.clearActiveJob(job.UserID) + + if job.Status == domain.StatusCompleted { + h.handleDownloadCompletion(ctx, job) + } +} + +// trackDownloadProgress reads progress updates from yt-dlp and updates the Telegram message. +func (h *Handler) trackDownloadProgress(ctx context.Context, job *domain.DownloadJob, updates <-chan *domain.DownloadJob) { lastProgress := 0.0 -loop: for { select { case upd, ok := <-updates: if !ok { - break loop + return } slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status) @@ -441,23 +427,21 @@ loop: if upd.Status == domain.StatusCancelled { h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil) - h.activeJobsMu.Lock() - delete(h.activeJobs, job.UserID) - h.activeJobsMu.Unlock() return } if upd.Status == domain.StatusFailed { h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil) - h.activeJobsMu.Lock() - delete(h.activeJobs, job.UserID) - h.activeJobsMu.Unlock() + return + } + + if upd.Status == domain.StatusCompleted { return } if upd.Progress > 0 && lastProgress == 0 { lastProgress = upd.Progress - } else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted { + } else if upd.Progress-lastProgress < domain.ProgressThreshold { continue } else { lastProgress = upd.Progress @@ -476,57 +460,54 @@ loop: dl.CancelDownload(job) for range updates { } - break loop + return } } +} - h.activeJobsMu.Lock() - delete(h.activeJobs, job.UserID) - h.activeJobsMu.Unlock() - - h.cancelFuncsMu.Lock() - delete(h.cancelFuncs, job.UserID) - h.cancelFuncsMu.Unlock() - - if job.Status == domain.StatusCompleted { - pattern := filepath.Join(h.downloadDir, job.ID+"_*") - files, err := filepath.Glob(pattern) - if err == nil && len(files) > 0 { - f := files[0] - 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 - } - - if !job.QuotaApplied && job.FileSize > 0 { - job.QuotaApplied = true - h.applyQuota(job.UserID, job.FileSize) - } - - formatID := job.SelectedFormat.ID - h.sendDocument(ctx, job.ChatID, f, job.Title) - os.Remove(f) - - _ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{ - URL: job.URL, - Title: job.Title, - MediaType: job.MediaType.String(), - FormatID: formatID, - Container: job.Container, - FileSize: job.FileSize, - Status: "completed", - }) - - h.editText(ctx, job.ChatID, job.MessageID, "Download completed!", nil) - return - } - } +// handleDownloadCompletion sends the downloaded file and records history. +func (h *Handler) handleDownloadCompletion(ctx context.Context, job *domain.DownloadJob) { + pattern := filepath.Join(h.downloadDir, job.ID+"_*") + files, err := filepath.Glob(pattern) + if err != nil || len(files) == 0 { h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", nil) + return } + + f := files[0] + fileInfo, err := os.Stat(f) + if err != nil { + h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", nil) + return + } + + 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 + } + + if !job.QuotaApplied && job.FileSize > 0 { + job.QuotaApplied = true + h.applyQuota(job.UserID, job.FileSize) + } + + h.sendDocument(ctx, job.ChatID, f, job.Title) + os.Remove(f) + + if err := h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{ + URL: job.URL, + Title: job.Title, + MediaType: job.MediaType.String(), + FormatID: job.SelectedFormat.ID, + FileSize: job.FileSize, + Status: "completed", + }); err != nil { + slog.Error("add history failed", "user_id", job.UserID, "error", err) + } + + h.editText(ctx, job.ChatID, job.MessageID, "Download completed!", nil) } func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, title string) { @@ -579,6 +560,7 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c defer h.answerCb(ctx, cbID) ss := h.getUserStepState(chatID) if ss == nil { + slog.Warn("nil stepState in handleBackStep", "chat_id", chatID, "user_id", userID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) return } @@ -589,26 +571,20 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c h.clearStepState(chatID) h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID) case 0: - // Re-show format list - deduped := dl.DeduplicateResolutions(ss.MediaInfo.Formats) - var videoFmts, audioFmts []domain.Format - for _, f := range deduped { - if f.HasVideo { - videoFmts = append(videoFmts, f) + ss.FormatIDs, _ = buildFormatList(ss.MediaInfo.Formats) + labels := make([]string, len(ss.FormatIDs)) + for i, id := range ss.FormatIDs { + if id == "best" { + labels[i] = "Best" } else { - audioFmts = append(audioFmts, f) + f := findFormatByID(ss.MediaInfo.Formats, id) + if f != nil { + labels[i] = formatLabelForDisplay(*f) + } else { + labels[i] = id + } } } - ss.FormatIDs = []string{"best"} - labels := []string{"Best"} - for _, f := range videoFmts { - ss.FormatIDs = append(ss.FormatIDs, f.ID) - labels = append(labels, formatLabelForDisplay(f)) - } - for _, f := range audioFmts { - ss.FormatIDs = append(ss.FormatIDs, f.ID) - labels = append(labels, formatLabelForDisplay(f)) - } h.setUserStepState(chatID, ss) kb := formatKeyboard("format_", labels, ss.FormatIDs) h.editText(ctx, chatID, msgID, "Select format:", &kb) @@ -644,6 +620,34 @@ func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID "Send me the URL to download:", nil) } +// buildFormatList returns a deduplicated format ID list with "best" first, then video, then audio. +func buildFormatList(formats []domain.Format) ([]string, error) { + deduped := dl.DeduplicateResolutions(formats) + ids := make([]string, 0, len(deduped)+1) + ids = append(ids, "best") + 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 +} + +// 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 { + if formats[i].ID == id { + return &formats[i] + } + } + return nil +} + func formatLabelForDisplay(f domain.Format) string { if f.Resolution != "" { label := f.Resolution @@ -721,7 +725,8 @@ func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *doma // downloadFile downloads a URL to a temp file in the given directory and returns the path. func downloadFile(url, dir string) (string, error) { - resp, err := http.Get(url) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) if err != nil { return "", fmt.Errorf("http get: %w", err) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index af7f3f6..13c8057 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -280,7 +280,7 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID case strings.HasPrefix(data, "lang_"): h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:]) case strings.HasPrefix(data, "container_"): - h.handleContainerSelection(ctx, chatID, msgID, userID, data[10:]) + // container selection was removed — yt-dlp handles it case strings.HasPrefix(data, "pl_page_"): h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:]) case strings.HasPrefix(data, "pl_entry_"): @@ -295,8 +295,6 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID } case data == "back_quality": h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality") - case data == "back_container": - h.handleSettingsSelection(ctx, chatID, msgID, userID, "container") case data == "back_settings": h.backToSettings(ctx, chatID, msgID, userID) case strings.HasPrefix(data, "settings_set_"): @@ -573,6 +571,7 @@ func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, func (h *Handler) applyQuota(userID int64, fileSize int64) { q, err := h.Repo.GetOrCreateQuotas(userID) if err != nil { + slog.Error("get quotas for apply", "user_id", userID, "error", err) return } q = h.resetQuotasIfNeeded(q) @@ -583,7 +582,9 @@ func (h *Handler) applyQuota(userID int64, fileSize int64) { q.DailyUsedMB += sizeMB q.WeeklyUsedMB += sizeMB q.MonthlyUsedMB += sizeMB - _ = h.Repo.UpdateQuotas(userID, q) + if err := h.Repo.UpdateQuotas(userID, q); err != nil { + slog.Error("update quotas", "user_id", userID, "error", err) + } } // resetQuotasIfNeeded resets quota counters if the period has changed. diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 0e8f332..196b70a 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -6,6 +6,126 @@ import ( "uptodownBot/internal/domain" ) +func TestBuildFormatList(t *testing.T) { + formats := []domain.Format{ + {ID: "1", HasVideo: true, HasAudio: false, Resolution: "1920x1080"}, + {ID: "2", HasVideo: false, HasAudio: true, Bitrate: "128k"}, + {ID: "3", HasVideo: true, HasAudio: false, Resolution: "1280x720"}, + {ID: "4", HasVideo: true, HasAudio: true, Resolution: "640x480"}, + } + ids, err := buildFormatList(formats) + if err != nil { + t.Fatalf("buildFormatList() error: %v", err) + } + if len(ids) == 0 { + t.Fatal("buildFormatList() returned empty list") + } + if ids[0] != "best" { + t.Errorf("first ID = %q, want best", ids[0]) + } + // Best, then all video formats, then all audio-only + wantVideo := map[string]bool{"1": true, "3": true, "4": true} + wantAudio := map[string]bool{"2": true} + seenAudio := false + for i := 1; i < len(ids); i++ { + if wantVideo[ids[i]] { + if seenAudio { + t.Errorf("video format %q found after audio-only formats", ids[i]) + } + } + if wantAudio[ids[i]] { + seenAudio = true + } + } +} + +func TestFindFormatByID(t *testing.T) { + formats := []domain.Format{ + {ID: "1", Resolution: "1920x1080"}, + {ID: "2", Bitrate: "128k"}, + } + f := findFormatByID(formats, "1") + if f == nil { + t.Fatal("findFormatByID(1) = nil, want non-nil") + } + if f.ID != "1" { + t.Errorf("findFormatByID(1).ID = %q, want 1", f.ID) + } + f = findFormatByID(formats, "nonexistent") + if f != nil { + t.Errorf("findFormatByID(nonexistent) = %+v, want nil", f) + } +} + +func TestStepStateDetermineMediaType(t *testing.T) { + tests := []struct { + name string + ss *stepState + want domain.MediaType + }{ + { + name: "nil main format", + ss: &stepState{}, + want: domain.MediaTypeVideoAudio, + }, + { + name: "audio only no secondary", + ss: &stepState{MainFormat: &domain.Format{IsAudioOnly: true}}, + want: domain.MediaTypeAudio, + }, + { + name: "audio only with secondary", + ss: &stepState{MainFormat: &domain.Format{IsAudioOnly: true}, SecondaryFmt: &domain.Format{HasVideo: true}}, + want: domain.MediaTypeVideoAudio, + }, + { + name: "has video, has audio", + ss: &stepState{MainFormat: &domain.Format{HasVideo: true, HasAudio: true}}, + want: domain.MediaTypeVideoAudio, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.ss.determineMediaType() + if got != tt.want { + t.Errorf("determineMediaType() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStepStateBuildFormatString(t *testing.T) { + tests := []struct { + name string + ss *stepState + want string + }{ + { + name: "best format", + ss: &stepState{MainFormatID: "best"}, + want: "", + }, + { + name: "single format", + ss: &stepState{MainFormatID: "137"}, + want: "137", + }, + { + name: "combined format", + ss: &stepState{MainFormatID: "137", SecondaryID: "140", SecondaryFmt: &domain.Format{ID: "140"}}, + want: "137+140", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.ss.buildFormatString() + if got != tt.want { + t.Errorf("buildFormatString() = %q, want %q", got, tt.want) + } + }) + } +} + func TestFormatBytes(t *testing.T) { tests := []struct { input int64 diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go index a0f0303..4b236ac 100644 --- a/internal/handler/keyboard.go +++ b/internal/handler/keyboard.go @@ -2,7 +2,6 @@ package handler import ( "fmt" - "strings" "github.com/go-telegram/bot/models" @@ -66,25 +65,6 @@ func languageKeyboard(languages []string) models.InlineKeyboardMarkup { return formatKeyboard("lang_", languages, languages) } -// containerKeyboard builds keyboard for container format selection. -func containerKeyboard(containers []string) models.InlineKeyboardMarkup { - rows := [][]models.InlineKeyboardButton{} - row := []models.InlineKeyboardButton{} - for i, c := range containers { - data := "container_" + c - row = append(row, models.InlineKeyboardButton{Text: strings.ToUpper(c), CallbackData: data}) - if len(row) == 3 || i == len(containers)-1 { - rows = append(rows, row) - row = nil - } - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back", CallbackData: "back_step"}, - {Text: "Cancel", CallbackData: "cancel_dl"}, - }) - return models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - // progressKeyboard builds the progress display with a dummy button and cancel. func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup { progressText := fmt.Sprintf("%.1f%%", pct) diff --git a/internal/handler/playlist.go b/internal/handler/playlist.go index 6b9582f..a352b4d 100644 --- a/internal/handler/playlist.go +++ b/internal/handler/playlist.go @@ -141,19 +141,15 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID prefs, prefErr := h.Repo.GetOrCreatePreferences(userID) if prefErr != nil { prefs = &domain.UserPreferences{ - DefaultMediaType: domain.MediaTypeVideoAudio, - DefaultAudioFormat: "best", - DefaultVideoFormat: "best", - DefaultAudioContainer: "mp3", - DefaultVideoContainer: "mp4", + DefaultMediaType: domain.MediaTypeVideoAudio, + DefaultAudioFormat: "best", + DefaultVideoFormat: "best", } } formatID := prefs.DefaultVideoFormat - container := prefs.DefaultVideoContainer if prefs.DefaultMediaType == domain.MediaTypeAudio { formatID = prefs.DefaultAudioFormat - container = prefs.DefaultAudioContainer } format := &domain.Format{ID: formatID} for _, f := range info.Formats { @@ -172,7 +168,6 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID URL: entry.URL, MediaType: prefs.DefaultMediaType, SelectedFormat: format, - Container: container, Language: prefs.DefaultLanguage, Status: domain.StatusDownloading, Title: entry.Title, diff --git a/internal/handler/settings.go b/internal/handler/settings.go index 5256cd0..f65cd2f 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -20,14 +20,6 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen if videoFmt == "" { videoFmt = "best" } - audioCont := prefs.DefaultAudioContainer - if audioCont == "" { - audioCont = "mp3" - } - videoCont := prefs.DefaultVideoContainer - if videoCont == "" { - videoCont = "mp4" - } langLabel := prefs.DefaultLanguage if langLabel == "" { langLabel = "Default" @@ -36,9 +28,6 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen rows := [][]models.InlineKeyboardButton{ { {Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"}, - }, - { - {Text: fmt.Sprintf("Container: %s/%s", audioCont, videoCont), CallbackData: "settings_container"}, {Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"}, }, {{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}}, @@ -82,15 +71,6 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg case "quality_video": text, kb := h.buildVideoQualityPicker(prefs) h.editText(ctx, chatID, msgID, text, &kb) - case "container": - text, kb := h.buildContainerAudioVideoPicker(prefs) - h.editText(ctx, chatID, msgID, text, &kb) - case "container_audio": - text, kb := h.buildAudioContainerPicker(prefs) - h.editText(ctx, chatID, msgID, text, &kb) - case "container_video": - text, kb := h.buildVideoContainerPicker(prefs) - h.editText(ctx, chatID, msgID, text, &kb) case "language": text, kb := h.buildLanguagePicker(prefs) h.editText(ctx, chatID, msgID, text, &kb) @@ -147,56 +127,6 @@ func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string return "Select default video quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows} } -func (h *Handler) buildContainerAudioVideoPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { - rows := [][]models.InlineKeyboardButton{ - {{Text: "Audio", CallbackData: "settings_container_audio"}}, - {{Text: "Video", CallbackData: "settings_container_video"}}, - {{Text: "Back to Settings", CallbackData: "back_settings"}}, - } - return "Choose container type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -func (h *Handler) buildAudioContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { - containers := []string{"mp3", "m4a", "opus"} - rows := [][]models.InlineKeyboardButton{} - for _, c := range containers { - label := c - if c == prefs.DefaultAudioContainer { - label = "> " + label - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: "settings_set_audiocontainer_" + c}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back", CallbackData: "back_container"}, - }) - return "Select default audio container:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -func (h *Handler) buildVideoContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { - containers := []string{"mp4", "mkv", "webm"} - rows := [][]models.InlineKeyboardButton{} - row := []models.InlineKeyboardButton{} - for i, c := range containers { - label := c - if c == prefs.DefaultVideoContainer { - label = "> " + label - } - row = append(row, models.InlineKeyboardButton{ - Text: label, CallbackData: "settings_set_videocontainer_" + c, - }) - if len(row) == 3 || i == len(containers)-1 { - rows = append(rows, row) - row = nil - } - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back", CallbackData: "back_container"}, - }) - return "Select default video container:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"} langLabels := map[string]string{ @@ -246,12 +176,6 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int case "videoquality": prefs.DefaultVideoFormat = value changed = true - case "audiocontainer": - prefs.DefaultAudioContainer = value - changed = true - case "videocontainer": - prefs.DefaultVideoContainer = value - changed = true case "language": prefs.DefaultLanguage = value changed = true