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:
2026-06-25 15:07:31 +03:30
parent 05fcdf7458
commit cd27b4d28d
15 changed files with 631 additions and 64 deletions

View File

@@ -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)
}