refactor: remove container selection, extract helpers, add logging and tests
All checks were successful
CI / build (push) Successful in 51s
All checks were successful
CI / build (push) Successful in 51s
- 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
This commit is contained in:
14
README.md
14
README.md
@@ -5,16 +5,18 @@ Telegram bot for downloading media from any site yt-dlp supports.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Download audio, video, or combined audio+video from YouTube, Spotify, SoundCloud, Vimeo, and hundreds more
|
- 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
|
- 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
|
- 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)
|
- 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)
|
- Cancel downloads at any time (terminates yt-dlp process)
|
||||||
- Per-user quota system (daily, weekly, monthly)
|
- Per-user quota system (daily, weekly, monthly; 0 = unlimited)
|
||||||
- User preferences (default quality, container, language)
|
- User preferences (default audio/video quality, language)
|
||||||
- Download history
|
- Download history with pagination
|
||||||
- Configurable file size limit
|
- Configurable file size limit (default 50 MB)
|
||||||
- Cookies support for age-restricted content
|
- Cookies support for age-restricted content
|
||||||
- Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
|
- Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
|
||||||
- Webhook and polling modes
|
- Webhook and polling modes
|
||||||
|
|||||||
@@ -265,28 +265,17 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
formatID := job.SelectedFormat.ID
|
formatID := job.SelectedFormat.ID
|
||||||
var args []string
|
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 != "" {
|
if job.FormatString != "" {
|
||||||
args = append(args, "-f", job.FormatString)
|
args = append(args, "-f", job.FormatString)
|
||||||
if job.Container != "" {
|
|
||||||
args = append(args, "--merge-output-format", job.Container)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Build format string for merge
|
|
||||||
switch job.MediaType {
|
switch job.MediaType {
|
||||||
case domain.MediaTypeAudio:
|
case domain.MediaTypeAudio:
|
||||||
args = append(args, "-f", formatID, "-x")
|
args = append(args, "-f", formatID, "-x")
|
||||||
if job.Container != "" {
|
|
||||||
args = append(args, "--audio-format", job.Container)
|
|
||||||
}
|
|
||||||
case domain.MediaTypeVideo:
|
case domain.MediaTypeVideo:
|
||||||
args = append(args, "-f", formatID)
|
args = append(args, "-f", formatID)
|
||||||
case domain.MediaTypeVideoAudio:
|
case domain.MediaTypeVideoAudio:
|
||||||
// Download best video + best audio that match
|
|
||||||
args = append(args, "-f", formatID+"+bestaudio/best")
|
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
|
// Progress reporting using progress-template for machine-readable output
|
||||||
args = append(args, "--newline")
|
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
|
// Language selection if specified
|
||||||
if job.Language != "" {
|
if job.Language != "" {
|
||||||
@@ -527,17 +516,3 @@ func formatLabel(f domain.Format) string {
|
|||||||
}
|
}
|
||||||
return f.ID
|
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"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
func TestFormatLabel(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
f domain.Format
|
f domain.Format
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ type DownloadJob struct {
|
|||||||
MediaType MediaType
|
MediaType MediaType
|
||||||
SelectedFormat *Format
|
SelectedFormat *Format
|
||||||
FormatString string
|
FormatString string
|
||||||
Container string
|
|
||||||
Language string
|
Language string
|
||||||
Status DownloadStatus
|
Status DownloadStatus
|
||||||
Progress float64
|
Progress float64
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ type stepState struct {
|
|||||||
SecondaryID string
|
SecondaryID string
|
||||||
SecondaryFmt *domain.Format
|
SecondaryFmt *domain.Format
|
||||||
Language string
|
Language string
|
||||||
Container string
|
|
||||||
FormatIDs []string
|
FormatIDs []string
|
||||||
PlaylistState *domain.PlaylistState
|
PlaylistState *domain.PlaylistState
|
||||||
SearchState *domain.SearchState
|
SearchState *domain.SearchState
|
||||||
@@ -138,30 +137,21 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
|
|
||||||
h.sendMediaPreview(ctx, chatID, info)
|
h.sendMediaPreview(ctx, chatID, info)
|
||||||
|
|
||||||
// Build unified format list: video formats first, then audio
|
ss.FormatIDs, _ = buildFormatList(info.Formats)
|
||||||
deduped := dl.DeduplicateResolutions(info.Formats)
|
h.setUserStepState(chatID, ss)
|
||||||
var videoFmts, audioFmts []domain.Format
|
labels := make([]string, len(ss.FormatIDs))
|
||||||
for _, f := range deduped {
|
for i, id := range ss.FormatIDs {
|
||||||
if f.HasVideo {
|
if id == "best" {
|
||||||
videoFmts = append(videoFmts, f)
|
labels[i] = "Best"
|
||||||
} else {
|
} 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)
|
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
||||||
h.sendWithKB(ctx, chatID, "Select format:", kb)
|
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) {
|
func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) {
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleFormatSelection", "chat_id", chatID, "user_id", userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ss.MainFormatID = formatID
|
ss.MainFormatID = formatID
|
||||||
@@ -183,7 +174,6 @@ func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID
|
|||||||
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range ss.MediaInfo.Formats {
|
for i := range ss.MediaInfo.Formats {
|
||||||
f := &ss.MediaInfo.Formats[i]
|
f := &ss.MediaInfo.Formats[i]
|
||||||
if f.ID == formatID {
|
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) {
|
func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) {
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleSecondarySelection", "chat_id", chatID, "user_id", userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +271,7 @@ func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, ms
|
|||||||
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
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) {
|
func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
||||||
ss.Step = 2
|
ss.Step = 2
|
||||||
h.setUserStepState(chatID, ss)
|
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)
|
h.editText(ctx, chatID, msgID, "Select language:", &kb)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.advanceToContainer(ctx, chatID, msgID, userID, ss)
|
h.startDownload(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *stepState) determineMediaType() domain.MediaType {
|
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) {
|
func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) {
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleLanguageSelection", "chat_id", chatID, "user_id", userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ss.Language = language
|
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.
|
// startDownload creates the download job and begins the download process.
|
||||||
func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, msgID int, userID int64, container string) {
|
func (h *Handler) startDownload(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
||||||
ss := h.getUserStepState(chatID)
|
|
||||||
if ss == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ss.Container = container
|
|
||||||
|
|
||||||
mediaType := ss.determineMediaType()
|
mediaType := ss.determineMediaType()
|
||||||
formatString := ss.buildFormatString()
|
formatString := ss.buildFormatString()
|
||||||
|
|
||||||
@@ -368,7 +344,6 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
|||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
SelectedFormat: format,
|
SelectedFormat: format,
|
||||||
FormatString: formatString,
|
FormatString: formatString,
|
||||||
Container: container,
|
|
||||||
Language: ss.Language,
|
Language: ss.Language,
|
||||||
Status: domain.StatusDownloading,
|
Status: domain.StatusDownloading,
|
||||||
Title: ss.MediaInfo.Title,
|
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 {
|
select {
|
||||||
case h.downloadSem <- struct{}{}:
|
case h.downloadSem <- struct{}{}:
|
||||||
case <-ctx.Done():
|
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)
|
updates, err := h.YtDlp.StartDownload(job, h.downloadDir)
|
||||||
if err != nil {
|
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
|
job.Status = domain.StatusFailed
|
||||||
h.activeJobsMu.Lock()
|
h.clearActiveJob(job.UserID)
|
||||||
delete(h.activeJobs, job.UserID)
|
|
||||||
h.activeJobsMu.Unlock()
|
|
||||||
return
|
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
|
lastProgress := 0.0
|
||||||
loop:
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case upd, ok := <-updates:
|
case upd, ok := <-updates:
|
||||||
if !ok {
|
if !ok {
|
||||||
break loop
|
return
|
||||||
}
|
}
|
||||||
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
|
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
|
||||||
|
|
||||||
@@ -441,23 +427,21 @@ loop:
|
|||||||
|
|
||||||
if upd.Status == domain.StatusCancelled {
|
if upd.Status == domain.StatusCancelled {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
||||||
h.activeJobsMu.Lock()
|
|
||||||
delete(h.activeJobs, job.UserID)
|
|
||||||
h.activeJobsMu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if upd.Status == domain.StatusFailed {
|
if upd.Status == domain.StatusFailed {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
||||||
h.activeJobsMu.Lock()
|
return
|
||||||
delete(h.activeJobs, job.UserID)
|
}
|
||||||
h.activeJobsMu.Unlock()
|
|
||||||
|
if upd.Status == domain.StatusCompleted {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if upd.Progress > 0 && lastProgress == 0 {
|
if upd.Progress > 0 && lastProgress == 0 {
|
||||||
lastProgress = upd.Progress
|
lastProgress = upd.Progress
|
||||||
} else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted {
|
} else if upd.Progress-lastProgress < domain.ProgressThreshold {
|
||||||
continue
|
continue
|
||||||
} else {
|
} else {
|
||||||
lastProgress = upd.Progress
|
lastProgress = upd.Progress
|
||||||
@@ -476,57 +460,54 @@ loop:
|
|||||||
dl.CancelDownload(job)
|
dl.CancelDownload(job)
|
||||||
for range updates {
|
for range updates {
|
||||||
}
|
}
|
||||||
break loop
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.activeJobsMu.Lock()
|
// handleDownloadCompletion sends the downloaded file and records history.
|
||||||
delete(h.activeJobs, job.UserID)
|
func (h *Handler) handleDownloadCompletion(ctx context.Context, job *domain.DownloadJob) {
|
||||||
h.activeJobsMu.Unlock()
|
pattern := filepath.Join(h.downloadDir, job.ID+"_*")
|
||||||
|
files, err := filepath.Glob(pattern)
|
||||||
h.cancelFuncsMu.Lock()
|
if err != nil || len(files) == 0 {
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", nil)
|
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) {
|
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)
|
defer h.answerCb(ctx, cbID)
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleBackStep", "chat_id", chatID, "user_id", userID)
|
||||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -589,26 +571,20 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c
|
|||||||
h.clearStepState(chatID)
|
h.clearStepState(chatID)
|
||||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||||
case 0:
|
case 0:
|
||||||
// Re-show format list
|
ss.FormatIDs, _ = buildFormatList(ss.MediaInfo.Formats)
|
||||||
deduped := dl.DeduplicateResolutions(ss.MediaInfo.Formats)
|
labels := make([]string, len(ss.FormatIDs))
|
||||||
var videoFmts, audioFmts []domain.Format
|
for i, id := range ss.FormatIDs {
|
||||||
for _, f := range deduped {
|
if id == "best" {
|
||||||
if f.HasVideo {
|
labels[i] = "Best"
|
||||||
videoFmts = append(videoFmts, f)
|
|
||||||
} else {
|
} 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)
|
h.setUserStepState(chatID, ss)
|
||||||
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
||||||
h.editText(ctx, chatID, msgID, "Select format:", &kb)
|
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)
|
"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 {
|
func formatLabelForDisplay(f domain.Format) string {
|
||||||
if f.Resolution != "" {
|
if f.Resolution != "" {
|
||||||
label := 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.
|
// downloadFile downloads a URL to a temp file in the given directory and returns the path.
|
||||||
func downloadFile(url, dir string) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("http get: %w", err)
|
return "", fmt.Errorf("http get: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
|||||||
case strings.HasPrefix(data, "lang_"):
|
case strings.HasPrefix(data, "lang_"):
|
||||||
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
||||||
case strings.HasPrefix(data, "container_"):
|
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_"):
|
case strings.HasPrefix(data, "pl_page_"):
|
||||||
h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:])
|
h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:])
|
||||||
case strings.HasPrefix(data, "pl_entry_"):
|
case strings.HasPrefix(data, "pl_entry_"):
|
||||||
@@ -295,8 +295,6 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
|||||||
}
|
}
|
||||||
case data == "back_quality":
|
case data == "back_quality":
|
||||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality")
|
h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality")
|
||||||
case data == "back_container":
|
|
||||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, "container")
|
|
||||||
case data == "back_settings":
|
case data == "back_settings":
|
||||||
h.backToSettings(ctx, chatID, msgID, userID)
|
h.backToSettings(ctx, chatID, msgID, userID)
|
||||||
case strings.HasPrefix(data, "settings_set_"):
|
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) {
|
func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
||||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
q, err := h.Repo.GetOrCreateQuotas(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("get quotas for apply", "user_id", userID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q = h.resetQuotasIfNeeded(q)
|
q = h.resetQuotasIfNeeded(q)
|
||||||
@@ -583,7 +582,9 @@ func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
|||||||
q.DailyUsedMB += sizeMB
|
q.DailyUsedMB += sizeMB
|
||||||
q.WeeklyUsedMB += sizeMB
|
q.WeeklyUsedMB += sizeMB
|
||||||
q.MonthlyUsedMB += 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.
|
// resetQuotasIfNeeded resets quota counters if the period has changed.
|
||||||
|
|||||||
@@ -6,6 +6,126 @@ import (
|
|||||||
"uptodownBot/internal/domain"
|
"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) {
|
func TestFormatBytes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input int64
|
input int64
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
||||||
@@ -66,25 +65,6 @@ func languageKeyboard(languages []string) models.InlineKeyboardMarkup {
|
|||||||
return formatKeyboard("lang_", languages, languages)
|
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.
|
// progressKeyboard builds the progress display with a dummy button and cancel.
|
||||||
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
|
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
|
||||||
progressText := fmt.Sprintf("%.1f%%", pct)
|
progressText := fmt.Sprintf("%.1f%%", pct)
|
||||||
|
|||||||
@@ -141,19 +141,15 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
|
|||||||
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||||
if prefErr != nil {
|
if prefErr != nil {
|
||||||
prefs = &domain.UserPreferences{
|
prefs = &domain.UserPreferences{
|
||||||
DefaultMediaType: domain.MediaTypeVideoAudio,
|
DefaultMediaType: domain.MediaTypeVideoAudio,
|
||||||
DefaultAudioFormat: "best",
|
DefaultAudioFormat: "best",
|
||||||
DefaultVideoFormat: "best",
|
DefaultVideoFormat: "best",
|
||||||
DefaultAudioContainer: "mp3",
|
|
||||||
DefaultVideoContainer: "mp4",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formatID := prefs.DefaultVideoFormat
|
formatID := prefs.DefaultVideoFormat
|
||||||
container := prefs.DefaultVideoContainer
|
|
||||||
if prefs.DefaultMediaType == domain.MediaTypeAudio {
|
if prefs.DefaultMediaType == domain.MediaTypeAudio {
|
||||||
formatID = prefs.DefaultAudioFormat
|
formatID = prefs.DefaultAudioFormat
|
||||||
container = prefs.DefaultAudioContainer
|
|
||||||
}
|
}
|
||||||
format := &domain.Format{ID: formatID}
|
format := &domain.Format{ID: formatID}
|
||||||
for _, f := range info.Formats {
|
for _, f := range info.Formats {
|
||||||
@@ -172,7 +168,6 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
|
|||||||
URL: entry.URL,
|
URL: entry.URL,
|
||||||
MediaType: prefs.DefaultMediaType,
|
MediaType: prefs.DefaultMediaType,
|
||||||
SelectedFormat: format,
|
SelectedFormat: format,
|
||||||
Container: container,
|
|
||||||
Language: prefs.DefaultLanguage,
|
Language: prefs.DefaultLanguage,
|
||||||
Status: domain.StatusDownloading,
|
Status: domain.StatusDownloading,
|
||||||
Title: entry.Title,
|
Title: entry.Title,
|
||||||
|
|||||||
@@ -20,14 +20,6 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
|
|||||||
if videoFmt == "" {
|
if videoFmt == "" {
|
||||||
videoFmt = "best"
|
videoFmt = "best"
|
||||||
}
|
}
|
||||||
audioCont := prefs.DefaultAudioContainer
|
|
||||||
if audioCont == "" {
|
|
||||||
audioCont = "mp3"
|
|
||||||
}
|
|
||||||
videoCont := prefs.DefaultVideoContainer
|
|
||||||
if videoCont == "" {
|
|
||||||
videoCont = "mp4"
|
|
||||||
}
|
|
||||||
langLabel := prefs.DefaultLanguage
|
langLabel := prefs.DefaultLanguage
|
||||||
if langLabel == "" {
|
if langLabel == "" {
|
||||||
langLabel = "Default"
|
langLabel = "Default"
|
||||||
@@ -36,9 +28,6 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
|
|||||||
rows := [][]models.InlineKeyboardButton{
|
rows := [][]models.InlineKeyboardButton{
|
||||||
{
|
{
|
||||||
{Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"},
|
{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: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
|
||||||
},
|
},
|
||||||
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
|
{{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":
|
case "quality_video":
|
||||||
text, kb := h.buildVideoQualityPicker(prefs)
|
text, kb := h.buildVideoQualityPicker(prefs)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
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":
|
case "language":
|
||||||
text, kb := h.buildLanguagePicker(prefs)
|
text, kb := h.buildLanguagePicker(prefs)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
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}
|
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) {
|
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||||
languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"}
|
languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"}
|
||||||
langLabels := map[string]string{
|
langLabels := map[string]string{
|
||||||
@@ -246,12 +176,6 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
|
|||||||
case "videoquality":
|
case "videoquality":
|
||||||
prefs.DefaultVideoFormat = value
|
prefs.DefaultVideoFormat = value
|
||||||
changed = true
|
changed = true
|
||||||
case "audiocontainer":
|
|
||||||
prefs.DefaultAudioContainer = value
|
|
||||||
changed = true
|
|
||||||
case "videocontainer":
|
|
||||||
prefs.DefaultVideoContainer = value
|
|
||||||
changed = true
|
|
||||||
case "language":
|
case "language":
|
||||||
prefs.DefaultLanguage = value
|
prefs.DefaultLanguage = value
|
||||||
changed = true
|
changed = true
|
||||||
|
|||||||
Reference in New Issue
Block a user