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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
84
internal/handler/handler_test.go
Normal file
84
internal/handler/handler_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user