Compare commits

...

4 Commits

Author SHA1 Message Date
3099244614 feat: add 'Ask' quality option, language auto-select, and m4a audio remux
All checks were successful
CI / build (push) Successful in 47s
- Add 'ask' option to audio/video quality settings (stored as '' in DB)
- Auto-select when both media type AND quality are set; show filtered
  format picker when quality is Ask
- Auto-skip language picker when user has a matching language preference
- Remux audio-only downloads to m4a instead of webm for Telegram compat
- Update README to document the new flow and m4a audio output
2026-06-28 12:17:08 +03:30
d0be877c0e fix: make cookies.txt writable so yt-dlp can save refreshed cookies
All checks were successful
CI / build (push) Successful in 46s
2026-06-28 12:03:44 +03:30
315107839c chore: remove cookies.txt from repo, add to gitignore
All checks were successful
CI / build (push) Successful in 50s
2026-06-28 12:01:01 +03:30
4af8a95af6 chore: add COOKIES_FILE to docker-compose environment 2026-06-28 12:00:53 +03:30
7 changed files with 97 additions and 22 deletions

19
.gitignore vendored
View File

@@ -1,10 +1,11 @@
.build/
*.exe
*.db
*.db-wal
*.db-shm
.env
.DS_Store
tmp/
opencode.json
.build/
cookies.txt
*.db
*.db-shm
*.db-wal
.DS_Store
.env
*.exe
opencode.json
tmp/

View File

@@ -10,11 +10,12 @@ Telegram bot for downloading media from any site yt-dlp supports.
- 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: type picker (Video/Audio/Ask), quality, secondary format (video+audio combine), language - Multi-step format selection: type picker (Video/Audio/Ask), quality, secondary format (video+audio combine), language
- Auto-select: skip the format picker entirely when defaults are set in user preferences - Auto-select: skip all pickers when both media type AND quality are set in preferences (quality "Ask" shows a filtered picker)
- Audio remuxed to m4a (Telegram-friendly), video remuxed to mp4
- Download progress updates (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; 0 = unlimited) - Per-user quota system (daily, weekly, monthly; 0 = unlimited)
- User preferences (default media type, audio/video quality, language) - User preferences (default media type, audio/video quality with "Ask" option, language)
- Download history with pagination - Download history with pagination
- Configurable file size limit (default 50 MB) - Configurable file size limit (default 50 MB)
- Cookies support for age-restricted and authenticated content - Cookies support for age-restricted and authenticated content

View File

@@ -12,10 +12,11 @@ services:
- HTTP_PROXY=${HTTP_PROXY} - HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY} - HTTPS_PROXY=${HTTPS_PROXY}
- DB_PATH=${DB_PATH} - DB_PATH=${DB_PATH}
- COOKIES_FILE=${COOKIES_FILE}
- TZ=${TZ} - TZ=${TZ}
volumes: volumes:
- uptodown_data:/data - uptodown_data:/data
- ./cookies.txt:/cookies.txt:ro # Optional: cookies file for yt-dlp auth - ./cookies.txt:/cookies.txt # Optional: cookies file for yt-dlp auth
volumes: volumes:
uptodown_data: uptodown_data:

View File

@@ -250,8 +250,10 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s") outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
args = append(args, "--output", outputPath) args = append(args, "--output", outputPath)
// Remux to mp4 for video output — Telegram-friendly container. // Remux to Telegram-friendly containers: mp4 for video, m4a for audio-only.
if job.MediaType != domain.MediaTypeAudio { if job.MediaType == domain.MediaTypeAudio {
args = append(args, "--merge-output-format", "m4a")
} else {
args = append(args, "--merge-output-format", "mp4") args = append(args, "--merge-output-format", "mp4")
} }

View File

@@ -161,9 +161,29 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID) prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk { if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk {
// Media type is set — check quality preference
var qualityPref string
switch prefs.DefaultMediaType {
case domain.MediaTypeVideo:
qualityPref = prefs.DefaultVideoFormat
case domain.MediaTypeAudio:
qualityPref = prefs.DefaultAudioFormat
}
if qualityPref != "" {
h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs) h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs)
return return
} }
// Quality is Ask — show filtered format picker
ss := &stepState{
URL: url,
MediaInfo: info,
Step: 0,
CreatedAt: time.Now(),
}
h.setUserStepState(chatID, ss)
h.showFormatsForType(ctx, chatID, userID, ss, prefs.DefaultMediaType.String())
return
}
ss := &stepState{ ss := &stepState{
URL: url, URL: url,
@@ -239,6 +259,34 @@ func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userI
h.advanceToLanguage(ctx, chatID, msgID, userID, ss) h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
} }
// showFormatsForType sends the format picker filtered by the given media type.
// Called when the user has a type preference but quality is Ask.
func (h *Handler) showFormatsForType(ctx context.Context, chatID int64, userID int64, ss *stepState, mediaType string) {
ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType)
if err != nil {
h.sendText(ctx, chatID, "No formats available.")
return
}
ss.FormatIDs = ids
h.setUserStepState(chatID, ss)
labels := make([]string, len(ids))
for i, id := range ids {
if id == "best" {
labels[i] = "Best"
} else {
f := findFormatByID(ss.MediaInfo.Formats, id)
if f != nil {
labels[i] = formatLabelForDisplay(*f)
} else {
labels[i] = id
}
}
}
kb := formatKeyboard("format_", labels, ids)
h.sendWithKB(ctx, chatID, "Select format:", kb)
}
// handleTypeSelection processes the initial type picker (Video/Audio/Ask). // handleTypeSelection processes the initial type picker (Video/Audio/Ask).
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, mediaType string) { func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, mediaType string) {
ss := h.getUserStepState(chatID) ss := h.getUserStepState(chatID)
@@ -394,6 +442,18 @@ func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int
h.setUserStepState(chatID, ss) h.setUserStepState(chatID, ss)
if len(ss.MediaInfo.Languages) > 1 { if len(ss.MediaInfo.Languages) > 1 {
// Check if user has a default language and it's available.
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
if prefErr == nil && prefs.DefaultLanguage != "" {
for _, lang := range ss.MediaInfo.Languages {
if lang == prefs.DefaultLanguage {
ss.Language = lang
h.startDownload(ctx, chatID, msgID, userID, ss)
return
}
}
}
// No match or no preference — show picker.
kb := languageKeyboard(ss.MediaInfo.Languages) kb := languageKeyboard(ss.MediaInfo.Languages)
h.editText(ctx, chatID, msgID, "Select language:", &kb) h.editText(ctx, chatID, msgID, "Select language:", &kb)
return return

View File

@@ -14,11 +14,11 @@ import (
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
audioFmt := prefs.DefaultAudioFormat audioFmt := prefs.DefaultAudioFormat
if audioFmt == "" { if audioFmt == "" {
audioFmt = "best" audioFmt = "ask"
} }
videoFmt := prefs.DefaultVideoFormat videoFmt := prefs.DefaultVideoFormat
if videoFmt == "" { if videoFmt == "" {
videoFmt = "best" videoFmt = "ask"
} }
langLabel := prefs.DefaultLanguage langLabel := prefs.DefaultLanguage
if langLabel == "" { if langLabel == "" {
@@ -101,11 +101,13 @@ func (h *Handler) buildQualityAudioVideoPicker(prefs *domain.UserPreferences) (s
} }
func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
bitrates := []string{"best", "128k", "192k", "256k", "320k"} bitrates := []string{"ask", "best", "128k", "192k", "256k", "320k"}
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, b := range bitrates { for _, b := range bitrates {
label := b label := b
if b == prefs.DefaultAudioFormat { if b == "ask" && prefs.DefaultAudioFormat == "" {
label = "> " + label
} else if b != "ask" && b == prefs.DefaultAudioFormat {
label = "> " + label label = "> " + label
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
@@ -119,12 +121,14 @@ func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string
} }
func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
resolutions := []string{"best", "2160p", "1440p", "1080p", "720p", "480p", "360p"} resolutions := []string{"ask", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{} row := []models.InlineKeyboardButton{}
for i, r := range resolutions { for i, r := range resolutions {
label := r label := r
if r == prefs.DefaultVideoFormat { if r == "ask" && prefs.DefaultVideoFormat == "" {
label = "> " + label
} else if r != "ask" && r == prefs.DefaultVideoFormat {
label = "> " + label label = "> " + label
} }
row = append(row, models.InlineKeyboardButton{ row = append(row, models.InlineKeyboardButton{
@@ -220,9 +224,15 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
} }
changed = true changed = true
case "audioquality": case "audioquality":
if value == "ask" {
value = ""
}
prefs.DefaultAudioFormat = value prefs.DefaultAudioFormat = value
changed = true changed = true
case "videoquality": case "videoquality":
if value == "ask" {
value = ""
}
prefs.DefaultVideoFormat = value prefs.DefaultVideoFormat = value
changed = true changed = true
case "language": case "language":

View File

@@ -65,7 +65,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (int64, error) {
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) { func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
if _, err := s.db.Exec( if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_preferences (user_id, default_media_type) VALUES (?, 'ask')", "INSERT OR IGNORE INTO user_preferences (user_id, default_media_type, default_audio_format, default_video_format) VALUES (?, 'ask', '', '')",
userID, userID,
); err != nil { ); err != nil {
return nil, err return nil, err