Files
uptodownbot/internal/handler/download.go
db123 6bbc87a7c8
All checks were successful
CI / build (push) Successful in 35s
fix: prevent context cancellation from blocking file delivery on cancel race
When a download completes but the user clicks Cancel at the same time,
handleDownloadCompletion's Telegram calls used the cancelled download
context and failed. Use context.Background() for all notification calls
so the file is always delivered regardless of download state.
2026-06-26 13:01:25 +03:30

831 lines
23 KiB
Go

package handler
import (
"context"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/dl"
"uptodownBot/internal/domain"
)
type stepState struct {
URL string
MediaInfo *domain.MediaInfo
Step int
MainFormatID string
MainFormat *domain.Format
SecondaryID string
SecondaryFmt *domain.Format
Language string
FormatIDs []string
SearchState *domain.SearchState
CreatedAt time.Time
}
const stepStateTTL = 30 * time.Minute
func (h *Handler) getUserStepState(chatID int64) *stepState {
h.stepStatesMu.Lock()
defer h.stepStatesMu.Unlock()
ss := h.stepStates[chatID]
if ss != nil && time.Since(ss.CreatedAt) > stepStateTTL {
delete(h.stepStates, chatID)
return nil
}
return ss
}
func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
h.stepStatesMu.Lock()
defer h.stepStatesMu.Unlock()
h.stepStates[chatID] = ss
}
func (h *Handler) clearStepState(chatID int64) {
h.stepStatesMu.Lock()
defer h.stepStatesMu.Unlock()
delete(h.stepStates, chatID)
}
// stripPlaylistParam removes the list parameter from YouTube/Music URLs.
// Playlists are not supported; this converts a video-with-playlist URL (e.g. a
// YouTube Music radio) to a plain video URL. Pure playlist URLs are rejected.
func stripPlaylistParam(rawURL string) (string, bool) {
// Reject pure playlist URLs (https://youtube.com/playlist?list=...)
if strings.Contains(rawURL, "/playlist") || strings.Contains(rawURL, "youtube.com/pL") {
return "", false
}
// Strip the list query parameter from watch URLs
url := rawURL
if strings.Contains(url, "list=") {
// Remove &list=... or ?list=...
url = regexp.MustCompile(`[?&]list=[^&]+`).ReplaceAllString(url, "")
// Clean up trailing ? or & if the param was the only one
url = strings.TrimRight(url, "?&")
}
return url, true
}
// handleURLInput is the entry point when a user sends a URL or types in Download mode.
func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) {
url, ok := domain.ValidateURL(text)
if !ok {
h.sendText(ctx, chatID, "Please send a valid URL (http/https).")
return
}
// Strip playlist parameter and reject pure playlists.
url, ok = stripPlaylistParam(url)
if !ok {
h.sendText(ctx, chatID, "Playlists are not supported. Send a single video/audio URL.")
return
}
if job := h.getActiveJob(userID); job != nil {
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
return
}
info, err := h.YtDlp.GetMediaInfo(url)
if err != nil {
errMsg := err.Error()
// DRM content (Spotify, etc.): fall back to YouTube search
if strings.Contains(errMsg, "DRM") {
youtubeURL, ytTitle, searchErr := h.YtDlp.SearchYoutube(text)
if searchErr != nil {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Search on YouTube", CallbackData: "drm_search"}},
{{Text: "Cancel", CallbackData: "pending_cancel"}},
},
}
h.sendWithKB(ctx, chatID, "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", kb)
return
}
h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle))
info, err = h.YtDlp.GetMediaInfo(youtubeURL)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("YouTube fallback failed: %s", err.Error()))
return
}
url = youtubeURL
} else {
switch {
case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"):
h.sendText(ctx, chatID, "This URL is not supported.")
case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"):
h.sendText(ctx, chatID, "This content requires authentication (cookies).")
case strings.Contains(errMsg, "live"):
h.sendText(ctx, chatID, "Cannot download live streams.")
default:
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg))
}
return
}
}
if len(info.Formats) == 0 {
h.sendText(ctx, chatID, "No formats available for this URL.")
return
}
// Reject if user has already exhausted quota (limit of 0 = unlimited).
q, qErr := h.Repo.GetOrCreateQuotas(userID)
if qErr == nil {
q = h.resetQuotasIfNeeded(q)
if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) ||
(q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) ||
(q.MonthlyLimitMB > 0 && q.MonthlyUsedMB >= q.MonthlyLimitMB) {
h.sendText(ctx, chatID, fmt.Sprintf(
"Quota exceeded. Daily: %d/%d MB, Weekly: %d/%d MB, Monthly: %d/%d MB.",
q.DailyUsedMB, q.DailyLimitMB, q.WeeklyUsedMB, q.WeeklyLimitMB, q.MonthlyUsedMB, q.MonthlyLimitMB))
return
}
}
h.sendMediaPreview(ctx, chatID, info)
ss := &stepState{
URL: url,
MediaInfo: info,
Step: 0,
CreatedAt: time.Now(),
}
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 {
f := findFormatByID(info.Formats, id)
if f != nil {
labels[i] = formatLabelForDisplay(*f)
} else {
labels[i] = id
}
}
}
kb := formatKeyboard("format_", labels, ss.FormatIDs)
h.sendWithKB(ctx, chatID, "Select format:", kb)
}
// handleFormatSelection processes a format pick from the unified list.
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
if formatID == "best" {
if ss.MediaInfo.IsAudioOnlyContent() {
ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true}
} else {
ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true}
}
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
return
}
for i := range ss.MediaInfo.Formats {
f := &ss.MediaInfo.Formats[i]
if f.ID == formatID {
ss.MainFormat = f
break
}
}
if ss.MainFormat == nil {
return
}
// Video-only: always offer audio track
if ss.MainFormat.HasVideo && !ss.MainFormat.HasAudio {
h.showSecondaryFormats(ctx, chatID, msgID, userID, ss, "audio")
return
}
// Audio-only: ask if they want to add video
if !ss.MainFormat.HasVideo && ss.MainFormat.HasAudio {
if ss.MediaInfo.IsAudioOnlyContent() {
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
return
}
text := "This format is audio-only. Add video?"
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Add Video", CallbackData: "secondary_video"}},
{{Text: "Audio Only", CallbackData: "secondary_skip"}},
{{Text: "Back", CallbackData: "back_step"}, {Text: "Cancel", CallbackData: "cancel_dl"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
return
}
// Has both: proceed
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
}
// showSecondaryFormats shows formats of the missing type to combine.
func (h *Handler) showSecondaryFormats(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState, kind string) {
var mt domain.MediaType
if kind == "audio" {
mt = domain.MediaTypeAudio
} else {
mt = domain.MediaTypeVideo
}
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, mt)
if len(filtered) == 0 {
ss.SecondaryFmt = nil
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
return
}
deduped := dl.DeduplicateResolutions(filtered)
ids := make([]string, len(deduped))
labels := make([]string, len(deduped))
for i, f := range deduped {
ids[i] = f.ID
labels[i] = formatLabelForDisplay(f)
}
ss.FormatIDs = ids
h.setUserStepState(chatID, ss)
prompt := fmt.Sprintf("Select %s format to combine:", kind)
kb := formatKeyboard("secondary_", labels, ids)
h.editText(ctx, chatID, msgID, prompt, &kb)
}
// handleSecondarySelection processes the secondary format selection.
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
}
if formatID == "skip" || formatID == "video" || formatID == "audio" {
// Handle the skip / add-video decisions from the audio-only prompt
if formatID == "video" {
h.showSecondaryFormats(ctx, chatID, msgID, userID, ss, "video")
return
}
// skip — just proceed
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
return
}
ss.SecondaryID = formatID
for i := range ss.MediaInfo.Formats {
f := &ss.MediaInfo.Formats[i]
if f.ID == formatID {
ss.SecondaryFmt = f
break
}
}
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
}
// 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)
if len(ss.MediaInfo.Languages) > 1 {
kb := languageKeyboard(ss.MediaInfo.Languages)
h.editText(ctx, chatID, msgID, "Select language:", &kb)
return
}
h.startDownload(ctx, chatID, msgID, userID, ss)
}
func (ss *stepState) determineMediaType() domain.MediaType {
if ss.MainFormat == nil {
return domain.MediaTypeVideoAudio
}
if ss.MainFormat.IsAudioOnly && ss.SecondaryFmt == nil {
return domain.MediaTypeAudio
}
return domain.MediaTypeVideoAudio
}
func (ss *stepState) buildFormatString() string {
if ss.MainFormatID == "best" {
return ""
}
if ss.SecondaryFmt != nil {
return ss.MainFormatID + "+" + ss.SecondaryID
}
return ss.MainFormatID
}
// handleLanguageSelection processes the language choice.
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.startDownload(ctx, chatID, msgID, userID, ss)
}
// 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()
format := &domain.Format{ID: ss.MainFormatID}
if ss.MainFormat != nil {
format = ss.MainFormat
}
fileSize := format.Filesize
if fileSize > 0 && fileSize > h.maxFileSize {
h.editText(ctx, chatID, msgID,
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileSize), formatBytes(h.maxFileSize)), nil)
return
}
dlCtx, dlCancel := context.WithCancel(context.Background())
job := &domain.DownloadJob{
ID: h.generateJobID(),
UserID: userID,
ChatID: chatID,
MessageID: msgID,
URL: ss.URL,
MediaType: mediaType,
SelectedFormat: format,
FormatString: formatString,
Language: ss.Language,
Status: domain.StatusDownloading,
Title: ss.MediaInfo.Title,
FileSize: fileSize,
MaxFileSize: h.maxFileSize,
Done: make(chan struct{}),
}
h.editText(ctx, chatID, msgID, "Processing...", nil)
h.setActiveJob(userID, job)
h.setCancelFunc(userID, dlCancel)
h.clearStepState(chatID)
go h.runDownload(dlCtx, job)
}
// runDownload manages the download lifecycle, progress, and completion.
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
defer func() {
if job.Done != nil {
close(job.Done)
}
}()
select {
case h.downloadSem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-h.downloadSem }()
kb := progressKeyboard(0, "", "")
progressHeader := "Downloading: " + job.Title
if job.ProgressPrefix != "" {
progressHeader = job.ProgressPrefix + "\n" + progressHeader
}
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: job.ChatID,
Text: progressHeader,
ReplyMarkup: &kb,
})
if err != nil {
slog.Error("send progress msg failed", "error", err)
return
}
job.MessageID = msg.ID
updates, err := h.YtDlp.StartDownload(job, h.downloadDir)
if err != nil {
errMsg := err.Error()
if job.StderrLog != "" {
if lines := strings.SplitN(job.StderrLog, "\n", 2); len(lines) > 0 && lines[0] != "" {
errMsg = strings.TrimSpace(lines[0])
}
}
h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", errMsg), nil)
job.Status = domain.StatusFailed
h.clearActiveJob(job.UserID)
return
}
h.trackDownloadProgress(ctx, job, updates)
if job.Status == domain.StatusCompleted {
h.handleDownloadCompletion(ctx, job)
}
h.clearActiveJob(job.UserID)
}
// 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
for {
select {
case upd, ok := <-updates:
if !ok {
return
}
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
h.activeJobsMu.Lock()
currentJob := h.activeJobs[job.UserID]
h.activeJobsMu.Unlock()
if currentJob == nil {
return
}
if upd.Status == domain.StatusCancelled {
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
return
}
if upd.Status == domain.StatusFailed {
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
return
}
if upd.Status == domain.StatusCompleted {
return
}
if lastProgress == 0 {
lastProgress = upd.Progress
} else {
diff := upd.Progress - lastProgress
// Skip small changes (< threshold) in either direction to avoid spamming edits.
// HLS streams often emit a fake 100% initial estimate then real values.
if diff >= 0 && diff < domain.ProgressThreshold {
continue
}
if diff < 0 && -diff < domain.ProgressThreshold {
continue
}
lastProgress = upd.Progress
}
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
job.QuotaApplied = true
h.applyQuota(job.UserID, job.FileSize)
}
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
text := "Downloading: " + job.Title
if job.ProgressPrefix != "" {
text = job.ProgressPrefix + "\n" + text
}
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
case <-ctx.Done():
dl.CancelDownload(job)
for range updates {
}
return
}
}
}
// handleDownloadCompletion sends the downloaded file and records history.
// Uses context.Background() for Telegram notifications so that cancellation
// of the download context does not prevent the file from being delivered.
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(context.Background(), 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(context.Background(), job.ChatID, job.MessageID, "Download completed but file not found.", nil)
return
}
if fileInfo.Size() > h.maxFileSize {
h.editText(context.Background(), 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(context.Background(), 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(context.Background(), job.ChatID, job.MessageID, "Download completed!", nil)
}
func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, title string) {
f, err := os.Open(filePath)
if err != nil {
slog.Error("open file for send", "path", filePath, "error", err)
return
}
defer f.Close()
doc := &models.InputFileUpload{
Filename: title + filepath.Ext(filePath),
Data: f,
}
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: doc,
}); err != nil {
slog.Error("send document failed", "error", err)
}
}
// handleCancelDownload cancels the current download for a user.
func (h *Handler) handleCancelDownload(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
h.cancelFuncsMu.Lock()
if cancel, ok := h.cancelFuncs[userID]; ok {
cancel()
delete(h.cancelFuncs, userID)
}
h.cancelFuncsMu.Unlock()
job := h.getActiveJob(userID)
if job != nil {
dl.CancelDownload(job)
h.activeJobsMu.Lock()
delete(h.activeJobs, userID)
h.activeJobsMu.Unlock()
h.editText(ctx, chatID, msgID, "Download cancelled.", nil)
return
}
h.clearStepState(chatID)
h.editText(ctx, chatID, msgID, "Cancelled.", nil)
}
// handleBackStep goes back one step in the download flow.
func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
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
}
ss.Step--
switch ss.Step {
case -1:
h.clearStepState(chatID)
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
case 0:
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 {
f := findFormatByID(ss.MediaInfo.Formats, id)
if f != nil {
labels[i] = formatLabelForDisplay(*f)
} else {
labels[i] = id
}
}
}
h.setUserStepState(chatID, ss)
kb := formatKeyboard("format_", labels, ss.FormatIDs)
h.editText(ctx, chatID, msgID, "Select format:", &kb)
case 1:
// Re-show secondary format list or main format with secondary info cleared
ss.SecondaryFmt = nil
ss.SecondaryID = ""
if ss.MainFormat != nil && ss.MainFormat.HasVideo && !ss.MainFormat.HasAudio {
h.showSecondaryFormats(ctx, chatID, msgID, userID, ss, "audio")
} else {
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
}
case 2:
if len(ss.MediaInfo.Languages) > 1 {
kb := languageKeyboard(ss.MediaInfo.Languages)
h.editText(ctx, chatID, msgID, "Select language:", &kb)
} else {
ss.Step = 1
h.handleBackStep(ctx, chatID, msgID, cbID, userID)
}
}
}
// 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) {
h.promptForInput(ctx, chatID, msgID, userID, PendingURL,
"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.IsAudioOnly {
label := f.Bitrate
if label == "" {
label = f.ID
if f.Extension != "" {
label += " (" + f.Extension + ")"
}
}
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
if f.Resolution != "" {
label := f.Resolution
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
if f.Bitrate != "" {
label := f.Bitrate
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
return f.ID
}
// 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 != "" {
thumbPath, err := downloadFile(info.Thumbnail, h.downloadDir)
if err != nil {
slog.Warn("download thumbnail failed, falling back to text", "url", info.Thumbnail, "error", err)
h.sendText(ctx, chatID, caption)
return
}
defer os.Remove(thumbPath)
f, err := os.Open(thumbPath)
if err != nil {
slog.Warn("open thumbnail failed, falling back to text", "error", err)
h.sendText(ctx, chatID, caption)
return
}
defer f.Close()
photo := &models.InputFileUpload{Filename: "thumb.jpg", Data: f}
_, err = h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{
ChatID: chatID, Photo: photo, Caption: caption,
})
if err == nil {
return
}
slog.Warn("send thumbnail photo failed, falling back to text", "error", err)
}
h.sendText(ctx, chatID, caption)
}
// downloadFile downloads a URL to a temp file in the given directory and returns the path.
func downloadFile(url, dir string) (string, error) {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
client := &http.Client{
Timeout: 30 * time.Second,
Transport: tr,
}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status: %s", resp.Status)
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create download dir: %w", err)
}
f, err := os.CreateTemp(dir, "thumb_*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
_, err = io.Copy(f, resp.Body)
if err != nil {
f.Close()
os.Remove(f.Name())
return "", fmt.Errorf("write temp file: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("close temp file: %w", err)
}
return f.Name(), nil
}
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)
}