Compare commits
2 Commits
315107839c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
3099244614
|
|||
|
d0be877c0e
|
@@ -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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
- 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:
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,27 @@ 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 {
|
||||||
h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs)
|
// 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)
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user