Compare commits

..

17 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
b11b5649a5 feat: add auto-select from preferences and type picker
All checks were successful
CI / build (push) Successful in 52s
- Add MediaTypeAsk (0) as the default, mapping old video_audio to Ask
- Show Video/Audio/Ask type picker when no default media type is set
- Auto-select matching format and skip picker when defaults are configured
- Add autoSelectVideoFormat / autoSelectAudioFormat helpers with closest-match
- Add buildFilteredFormatList for type-filtered format lists
- Add Media Type setting in settings UI (Ask/Video/Audio)
- Add --merge-output-format mp4 for video downloads via yt-dlp
- Fix formatLabelForDisplay to show Height fallback and Extension
- Add cookies volume mount to docker-compose.yml
- Document cookies setup and new features in README
2026-06-28 11:28:53 +03:30
6bbc87a7c8 fix: prevent context cancellation from blocking file delivery on cancel race
All checks were successful
CI / build (push) Successful in 35s
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
d0291d1e9e refactor: remove all playlist support, fix message ordering
All checks were successful
CI / build (push) Successful in 54s
- Remove playlist handler, keyboard, and all related callback routing
- Strip playlist parameter from YouTube Music radio URLs, reject pure playlists
- Send media preview before format selection prompt (correct ordering)
- Remove DetectPlaylist/GetPlaylistInfo methods (no longer needed)
- Remove IsPlaylist, PlaylistCount, PlaylistEntries from MediaInfo
- Remove PlaylistState struct, keep PlaylistEntry for search results
- Update README to reflect removal of playlist feature
2026-06-26 12:56:10 +03:30
3f56ffbba9 fix: resolve download failures and duplicate messages, add fast playlist detection
All checks were successful
CI / build (push) Successful in 39s
- Remove --audio-multi-streams flag (not supported by Alpine yt-dlp 2026.03.17)
- Reuse the fetching message as the format selection message to avoid duplicate text
- Add DetectPlaylist method for fast flat playlist detection before expensive GetMediaInfo
- This avoids stalls on YouTube Music radio URLs with large playlists
2026-06-26 12:39:11 +03:30
f5204b9554 fix: show bitrate for audio-only formats, filter subtitle languages, improve feedback
All checks were successful
CI / build (push) Successful in 50s
- Prioritize IsAudioOnly check in formatLabelForDisplay so bitrate
  is shown instead of yt-dlp's generic 'audio only' resolution
- Only collect languages from formats with audio/video to prevent
  subtitle-only tracks from triggering the language selection prompt
- Show 'Fetching metadata...' message before GetMediaInfo and edit
  with results, improving UX for URL input
- Add test coverage for audio-only format labels
2026-06-26 12:12:33 +03:30
464f20a46c feat: bundle hanime-plugin and Deno JS runtime in Docker image
All checks were successful
CI / build (push) Successful in 56s
- Add PIP_BREAK_SYSTEM_PACKAGES=1 to install hanime-plugin via pip
- Install Deno JS runtime required by the plugin's extractors
- Document plugin setup and JS runtime requirement in README
- Note plugin support in feature list
2026-06-26 01:27:10 +03:30
609237f4e3 fix: resolve 8 architecture flaws across download, playlist, and webhook
All checks were successful
CI / build (push) Successful in 52s
- Add --continue and --max-filesize to yt-dlp for download resilience
- Route thumbnail downloads through HTTP_PROXY
- Show per-video progress (i/N) with cancel for playlist downloads
- Run playlist asynchronously so cancel callbacks can be processed
- Guard handlePlaylistConfirm against concurrent active jobs
- Close Done channel in playlist entry lifecycle
- Add stepState TTL (30 min) to prevent stale states
- Wrap webhook server for graceful shutdown on SIGTERM
- Refactor formatBytes to loop-based implementation
- Show first line of stderr in user-facing error messages
- Fix format pointer reuse in playlist loop
- Add CreatedAt field to stepState for TTL tracking
- Add MaxFileSize and ProgressPrefix fields to DownloadJob
2026-06-26 01:04:34 +03:30
57dffc4194 fix: correct progress display (remove duplicate /s, handle HLS reset)
All checks were successful
CI / build (push) Successful in 47s
2026-06-26 00:46:26 +03:30
58baa1b769 fix: read yt-dlp progress from stdout instead of stderr; add explicit --proxy support
All checks were successful
CI / build (push) Successful in 52s
2026-06-26 00:41:04 +03:30
df08a8840f fix: add --progress flag to force progress in non-TTY pipes
All checks were successful
CI / build (push) Successful in 50s
yt-dlp suppresses progress output by default when not in a TTY.
The --progress flag overrides this. Without it, readProgress sees
only 4 stderr lines (no [download] progress) and reports 0 matches.
This was the root cause of progress always showing 0.0%.
2026-06-26 00:09:41 +03:30
959b7e388d fix: remove --progress-template, parse default [download] output instead
All checks were successful
CI / build (push) Successful in 40s
--progress-template output is suppressed in non-TTY subprocesses
(yt-dlp issue #13649). The default [download] progress format
works reliably in pipes. Changes:

- Remove --progress-template from yt-dlp args entirely
- Keep --newline for newline-delimited output in pipe
- Fix progressRE regex to handle HLS format: ~ with leading
  spaces before size (e.g. '~   1.00KiB')
- Fix speed parsing in [download] fallback to handle multiple
  leading spaces (e.g. 'at    976.20B/s')
- Fix ETA parsing to strip (frag n/m) suffix
- Filter Unknown/N/A speed/eta in [download] fallback
- Add test cases for real HLS fragment output format
2026-06-26 00:01:35 +03:30
7c02cd3d89 fix: progress always at 0% and context cancelled before sendDocument
All checks were successful
CI / build (push) Successful in 50s
- Fix progress template: remove spurious stderr: prefix and switch to
  _percent_str/_speed_str/_eta_str keys (progress.percent outputs NA
  for HLS streams with unknown total size)
- Update progress regex to match new template format:
  DLPROG| 45.2%|   1.05MiB/s|00:45
- Fix context cancellation bug: clearActiveJob was cancelling dlCtx
  before handleDownloadCompletion could send the document
- Update progress test cases to match new template output format
2026-06-25 23:49:27 +03:30
37e7f918d0 refactor: remove container selection, extract helpers, add logging and tests
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
2026-06-25 23:41:26 +03:30
20 changed files with 1233 additions and 757 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

@@ -14,7 +14,18 @@ RUN go build -o /usr/local/bin/uptodown-bot ./cmd/bot/
FROM alpine:3.23 FROM alpine:3.23
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates tzdata RUN apk add --no-cache ffmpeg yt-dlp ca-certificates tzdata py3-pip curl
# Install yt-dlp plugins (add more as needed).
# PIP_BREAK_SYSTEM_PACKAGES bypasses PEP 668 — safe in Docker.
RUN PIP_BREAK_SYSTEM_PACKAGES=1 pip3 install --root-user-action=ignore --no-cache-dir hanime-plugin
# Install Deno JS runtime (required by some yt-dlp plugins)
RUN curl -fsSL https://deno.land/install.sh | sh
ENV PATH="/root/.deno/bin:${PATH}"
# Verify Deno is available
RUN deno --version
WORKDIR /data WORKDIR /data

View File

@@ -5,20 +5,24 @@ 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: type picker (Video/Audio/Ask), quality, secondary format (video+audio combine), language
- Playlist support with paginated entry selection (10 per page, select all, confirm with count) - Auto-select: skip all pickers when both media type AND quality are set in preferences (quality "Ask" shows a filtered picker)
- Download progress updates (every 5% with speed and ETA) - Audio remuxed to m4a (Telegram-friendly), video remuxed to mp4
- 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 media type, audio/video quality with "Ask" option, 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 and authenticated 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
- SQLite database for persistence - SQLite database for persistence
- yt-dlp plugin support — `hanime-plugin` bundled in Docker image
## Commands ## Commands
@@ -50,6 +54,17 @@ Copy `.env.example` to `.env` and configure:
| `BOT_TLS_CERT` | empty | TLS certificate path | | `BOT_TLS_CERT` | empty | TLS certificate path |
| `BOT_TLS_KEY` | empty | TLS key path | | `BOT_TLS_KEY` | empty | TLS key path |
### Cookies (for age-restricted and authenticated content)
Some sites (YouTube, Spotify, etc.) require cookies for age-restricted content. To set up:
1. Install a browser extension that exports cookies in Netscape format (e.g., "Get cookies.txt" for Chrome/Firefox).
2. Log into the site with a throwaway account.
3. Export cookies to a file named `cookies.txt` in the project root.
4. Set `COOKIES_FILE=/cookies.txt` in your `.env` file.
For Docker, the cookie file is automatically mounted from `./cookies.txt` (see `docker-compose.yml`).
## Running ## Running
### Polling mode (default) ### Polling mode (default)
@@ -73,6 +88,46 @@ make run-dev
make docker-run make docker-run
``` ```
The Docker image bundles the `hanime-plugin` and the Deno JS runtime for sites like
hanime.tv, hstream.moe, and hentaihaven.com. See [yt-dlp plugins](#yt-dlp-plugins) for details.
### Local (non-Docker)
If running outside Docker, the bot uses whatever yt-dlp is in your PATH.
Install plugins and a JS runtime as described below under [yt-dlp plugins](#yt-dlp-plugins).
## yt-dlp plugins
Some sites require yt-dlp plugins or a JavaScript runtime to work. The Docker image
bundles these automatically; for local installs you need to set them up manually.
### hanime-plugin
Adds support for hanime.tv, hstream.moe, hentaihaven.com and others.
```bash
pip install hanime-plugin
```
The plugin requires a JavaScript runtime. [Deno](https://deno.com) is recommended:
```bash
# Linux/macOS
curl -fsSL https://deno.land/install.sh | sh
# Add to your shell config:
export PATH="$HOME/.deno/bin:$PATH"
```
### Adding other plugins
yt-dlp plugins are Python packages installed via pip. Most work without
additional runtimes. Install any pip-installable yt-dlp plugin with:
```bash
pip install <plugin-name>
```
## Development ## Development
```bash ```bash

View File

@@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"time"
tgbot "github.com/go-telegram/bot" tgbot "github.com/go-telegram/bot"
) )
@@ -23,10 +24,26 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) {
tlsCert := os.Getenv("BOT_TLS_CERT") tlsCert := os.Getenv("BOT_TLS_CERT")
tlsKey := os.Getenv("BOT_TLS_KEY") tlsKey := os.Getenv("BOT_TLS_KEY")
server := &http.Server{
Addr: listenAddr,
Handler: b.WebhookHandler(),
}
// Shutdown HTTP server gracefully when context is cancelled
go func() {
<-ctx.Done()
slog.Info("shutting down webhook server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("webhook server shutdown", "error", err)
}
}()
if tlsCert != "" && tlsKey != "" { if tlsCert != "" && tlsKey != "" {
slog.Info("starting HTTPS webhook", "addr", listenAddr) slog.Info("starting HTTPS webhook", "addr", listenAddr)
go func() { go func() {
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, b.WebhookHandler()); err != nil { if err := server.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed {
slog.Error("HTTPS server", "error", err) slog.Error("HTTPS server", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -34,7 +51,7 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) {
} else { } else {
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr) slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
go func() { go func() {
if err := http.ListenAndServe(listenAddr, b.WebhookHandler()); err != nil { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server", "error", err) slog.Error("HTTP server", "error", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -12,9 +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 # Optional: cookies file for yt-dlp auth
volumes: volumes:
uptodown_data: uptodown_data:

View File

@@ -11,8 +11,8 @@ import (
"uptodownBot/internal/domain" "uptodownBot/internal/domain"
) )
var progressRE = regexp.MustCompile(`\[\w+\]\s+([\d.]+)%\s+of\s+~?([\d.]+)(\w+)`) var progressRE = regexp.MustCompile(`\[\w+\]\s+([\d.]+)%\s+of\s+~?\s*([\d.]+)(\w+)`)
var progressTemplateRE = regexp.MustCompile(`^DLPROG\|([\d.]+)\|(.*?)\|(.*)$`) var progressTemplateRE = regexp.MustCompile(`^DLPROG\|\s*([\d.]+)%\|\s*(.*?)\s*\|(.*)$`)
type progressUpdate struct { type progressUpdate struct {
pct float64 pct float64
@@ -71,36 +71,43 @@ func parseProgressLine(line string) *progressUpdate {
rest = rest[idx+1:] rest = rest[idx+1:]
} }
if atIdx := strings.Index(rest, " at "); atIdx >= 0 { if atIdx := strings.Index(rest, " at "); atIdx >= 0 {
afterAt := rest[atIdx+4:] afterAt := strings.TrimSpace(rest[atIdx+4:])
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 { if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 {
u.speed = strings.TrimSpace(afterAt[:spaceIdx]) speedVal := afterAt[:spaceIdx]
if !strings.EqualFold(speedVal, "unknown") && speedVal != "N/A" {
u.speed = speedVal
}
rest = afterAt[spaceIdx:] rest = afterAt[spaceIdx:]
} else {
if !strings.EqualFold(afterAt, "unknown") && afterAt != "N/A" {
u.speed = afterAt
}
rest = ""
} }
} }
if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 { if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 {
etaStr := strings.TrimSpace(rest[etaIdx+4:]) etaStr := strings.TrimSpace(rest[etaIdx+4:])
if parenPos := strings.Index(etaStr, "("); parenPos > 0 {
etaStr = strings.TrimSpace(etaStr[:parenPos])
}
if !strings.EqualFold(etaStr, "unknown") && etaStr != "N/A" {
u.eta = etaStr u.eta = etaStr
} }
}
return u return u
} }
// readProgress reads yt-dlp stderr and sends progress updates to the channel. // readProgress reads yt-dlp output and sends progress updates to the channel.
// The caller is responsible for closing the updates channel. // The caller is responsible for closing the updates channel.
func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) { func readProgress(reader io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
var stderrBuf strings.Builder scanner := bufio.NewScanner(reader)
scanner := bufio.NewScanner(stderr)
lineCount := 0 lineCount := 0
matchCount := 0 matchCount := 0
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
lineCount++ lineCount++
if stderrBuf.Len() < 4096 {
stderrBuf.WriteString(line)
stderrBuf.WriteByte('\n')
}
parsed := parseProgressLine(line) parsed := parseProgressLine(line)
if parsed == nil { if parsed == nil {
continue continue
@@ -111,6 +118,5 @@ func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job
job.ETA = parsed.eta job.ETA = parsed.eta
updates <- job updates <- job
} }
job.StderrLog = stderrBuf.String() slog.Info("readProgress finished", "url", job.URL, "lines", lineCount, "matches", matchCount)
slog.Info("readProgress finished", "url", job.URL, "lines", lineCount, "matches", matchCount, "stderr_len", len(job.StderrLog))
} }

View File

@@ -67,6 +67,27 @@ func TestParseProgressLine(t *testing.T) {
eta: "00:02", eta: "00:02",
hasRes: true, hasRes: true,
}, },
{
line: "[download] 100.0% of ~ 1.00KiB at 976.20B/s ETA Unknown (frag 0/18)",
pct: 100.0,
speed: "976.20B/s",
eta: "",
hasRes: true,
},
{
line: "[download] 16.7% of ~ 18.00KiB at 976.20B/s ETA Unknown (frag 0/18)",
pct: 16.7,
speed: "976.20B/s",
eta: "",
hasRes: true,
},
{
line: "[download] 11.1% of ~ 8.34MiB at 716.47KiB/s ETA 00:08 (frag 1/18)",
pct: 11.1,
speed: "716.47KiB/s",
eta: "00:08",
hasRes: true,
},
{ {
line: "[download] 1.2% of unknown size at 0.00B/s ETA Unknown", line: "[download] 1.2% of unknown size at 0.00B/s ETA Unknown",
hasRes: false, hasRes: false,
@@ -109,21 +130,21 @@ func TestParseProgressTemplate(t *testing.T) {
hasRes bool hasRes bool
}{ }{
{ {
line: "DLPROG|45.2|2.34MiB/s|00:45", line: "DLPROG| 45.2%| 2.34MiB/s|00:45",
pct: 45.2, pct: 45.2,
speed: "2.34MiB/s", speed: "2.34MiB/s",
eta: "00:45", eta: "00:45",
hasRes: true, hasRes: true,
}, },
{ {
line: "DLPROG|100.0|5.00MiB/s|00:00", line: "DLPROG|100.0%| 5.00MiB/s|00:00",
pct: 100.0, pct: 100.0,
speed: "5.00MiB/s", speed: "5.00MiB/s",
eta: "00:00", eta: "00:00",
hasRes: true, hasRes: true,
}, },
{ {
line: "DLPROG|0.0||", line: "DLPROG| 0.0%||",
pct: 0.0, pct: 0.0,
speed: "", speed: "",
eta: "", eta: "",
@@ -142,12 +163,26 @@ func TestParseProgressTemplate(t *testing.T) {
hasRes: false, hasRes: false,
}, },
{ {
line: "DLPROG|50.0|Unknown|Unknown", line: "DLPROG| 50.0%| Unknown|Unknown",
pct: 50.0, pct: 50.0,
speed: "", speed: "",
eta: "", eta: "",
hasRes: true, hasRes: true,
}, },
{
line: "DLPROG| 16.7%| 1.05KiB/s|Unknown",
pct: 16.7,
speed: "1.05KiB/s",
eta: "",
hasRes: true,
},
{
line: "DLPROG| 11.1%| 754.59KiB/s|00:08",
pct: 11.1,
speed: "754.59KiB/s",
eta: "00:08",
hasRes: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
got := parseProgressTemplate(tt.line) got := parseProgressTemplate(tt.line)
@@ -182,6 +217,9 @@ func TestParseProgressLineEdgeCases(t *testing.T) {
{"[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", "multiple spaces", true}, {"[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", "multiple spaces", true},
{"[download] 100.0% of 1.00GiB at 999.99MiB/s ETA 00:00", "high speed", true}, {"[download] 100.0% of 1.00GiB at 999.99MiB/s ETA 00:00", "high speed", true},
{"[download] 100.0% of 999.99TiB at 1.00TiB/s ETA 00:00", "large numbers", true}, {"[download] 100.0% of 999.99TiB at 1.00TiB/s ETA 00:00", "large numbers", true},
{"[download] 100.0% of ~ 1.00KiB at 976.20B/s ETA Unknown (frag 0/18)", "hls frag first", true},
{"[download] 16.7% of ~ 18.00KiB at 976.20B/s ETA Unknown (frag 0/18)", "hls frag", true},
{"[download] 11.1% of ~ 8.34MiB at 716.47KiB/s ETA 00:08 (frag 1/18)", "hls frag with eta", true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@@ -5,10 +5,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -21,6 +23,7 @@ import (
type Client struct { type Client struct {
binaryPath string binaryPath string
cookiesFile string cookiesFile string
proxyURL string
cache map[string]*cachedMediaInfo cache map[string]*cachedMediaInfo
cacheMu sync.Mutex cacheMu sync.Mutex
} }
@@ -60,9 +63,19 @@ func NewClient(cookiesFile string) (*Client, error) {
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err) return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
} }
slog.Info("yt-dlp found", "path", path) slog.Info("yt-dlp found", "path", path)
proxyURL := os.Getenv("HTTP_PROXY")
if proxyURL == "" {
proxyURL = os.Getenv("HTTPS_PROXY")
}
if proxyURL != "" {
slog.Info("using proxy for yt-dlp", "proxy", proxyURL)
}
return &Client{ return &Client{
binaryPath: path, binaryPath: path,
cookiesFile: cookiesFile, cookiesFile: cookiesFile,
proxyURL: proxyURL,
cache: make(map[string]*cachedMediaInfo), cache: make(map[string]*cachedMediaInfo),
}, nil }, nil
} }
@@ -118,6 +131,9 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
if c.cookiesFile != "" { if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile) args = append(args, "--cookies", c.cookiesFile)
} }
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
slog.Info("fetching media info", "url", url) slog.Info("fetching media info", "url", url)
out, err := c.run(args) out, err := c.run(args)
@@ -140,14 +156,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
Uploader: info.Uploader, Uploader: info.Uploader,
Thumbnail: info.Thumbnail, Thumbnail: info.Thumbnail,
Duration: info.Duration, Duration: info.Duration,
IsPlaylist: info.Playlist != "",
}
if mi.IsPlaylist {
mi.PlaylistCount = info.PlaylistCount
mi.PlaylistEntries = c.parsePlaylistEntries(info.Entries)
c.setCache(url, mi)
return mi, nil
} }
langSet := make(map[string]bool) langSet := make(map[string]bool)
@@ -155,7 +163,7 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
for _, f := range info.Formats { for _, f := range info.Formats {
ff := c.convertFormat(f) ff := c.convertFormat(f)
mi.Formats = append(mi.Formats, ff) mi.Formats = append(mi.Formats, ff)
if ff.Language != "" && !langSet[ff.Language] { if (ff.HasAudio || ff.HasVideo) && ff.Language != "" && !langSet[ff.Language] {
langSet[ff.Language] = true langSet[ff.Language] = true
mi.Languages = append(mi.Languages, ff.Language) mi.Languages = append(mi.Languages, ff.Language)
} }
@@ -169,47 +177,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
return mi, nil return mi, nil
} }
// GetPlaylistInfo fetches a flat playlist listing (fast, no format detail).
func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
if cached := c.getCached(url); cached != nil {
slog.Debug("cache hit", "url", url)
return cached, nil
}
args := []string{
"-J", "--flat-playlist", "--no-download",
"--no-warnings",
url,
}
if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile)
}
slog.Info("fetching playlist info", "url", url)
out, err := c.run(args)
if err != nil {
return nil, fmt.Errorf("yt-dlp playlist info failed: %w", err)
}
var info ytdlpInfo
if err := json.Unmarshal(out, &info); err != nil {
return nil, fmt.Errorf("parse yt-dlp playlist output: %w", err)
}
mi := &domain.MediaInfo{
URL: url,
Title: info.Title,
Uploader: info.Uploader,
Thumbnail: info.Thumbnail,
IsPlaylist: true,
PlaylistCount: info.PlaylistCount,
PlaylistEntries: c.parsePlaylistEntries(info.Entries),
}
c.setCache(url, mi)
return mi, nil
}
func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry { func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry {
var result []domain.PlaylistEntry var result []domain.PlaylistEntry
for _, raw := range entries { for _, raw := range entries {
@@ -265,28 +232,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)
}
} }
} }
@@ -294,20 +250,37 @@ 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)
// Progress reporting using progress-template for machine-readable output // Remux to Telegram-friendly containers: mp4 for video, m4a for audio-only.
args = append(args, "--newline") if job.MediaType == domain.MediaTypeAudio {
args = append(args, "--progress-template", "stderr:DLPROG|%(progress.percent)s|%(progress._speed_str)s|%(progress._eta_str)s") args = append(args, "--merge-output-format", "m4a")
} else {
args = append(args, "--merge-output-format", "mp4")
}
// --progress forces progress output in non-TTY (pipe) environments
// --newline uses \n instead of \r so bufio.Scanner can read line-by-line
args = append(args, "--progress", "--newline")
// Allow resuming partial downloads
args = append(args, "--continue")
// Enforce max file size limit
if job.MaxFileSize > 0 {
args = append(args, "--max-filesize", strconv.FormatInt(job.MaxFileSize, 10))
}
// Language selection if specified // Language selection if specified
if job.Language != "" { if job.Language != "" {
args = append(args, "--sub-langs", job.Language) args = append(args, "--sub-langs", job.Language)
args = append(args, "--audio-multi-streams")
} }
// Cookies // Cookies
if c.cookiesFile != "" { if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile) args = append(args, "--cookies", c.cookiesFile)
} }
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
args = append(args, job.URL) args = append(args, job.URL)
@@ -316,6 +289,13 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
cmd := exec.Command(c.binaryPath, args...) cmd := exec.Command(c.binaryPath, args...)
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1") cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
// yt-dlp outputs [download] progress lines to stdout when saving to a file
// and to stderr when using -o -. Since we save to a file, read stdout for progress.
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe() stderr, err := cmd.StderrPipe()
if err != nil { if err != nil {
return nil, fmt.Errorf("create stderr pipe: %w", err) return nil, fmt.Errorf("create stderr pipe: %w", err)
@@ -340,10 +320,16 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
progressDone := make(chan struct{}) progressDone := make(chan struct{})
go func() { go func() {
readProgress(stderr, updates, job) readProgress(stdout, updates, job)
close(progressDone) close(progressDone)
}() }()
// Capture stderr (warnings, errors) for diagnostics
go func() {
slurp, _ := io.ReadAll(stderr)
job.StderrLog = string(slurp)
}()
// Wait for either completion or cancellation // Wait for either completion or cancellation
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
@@ -406,6 +392,9 @@ func (c *Client) SearchYoutube(query string) (url, title string, err error) {
if c.cookiesFile != "" { if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile) args = append(args, "--cookies", c.cookiesFile)
} }
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
out, err := c.run(args) out, err := c.run(args)
if err != nil { if err != nil {
@@ -439,6 +428,9 @@ func (c *Client) Search(query, source string, limit int) ([]domain.PlaylistEntry
if c.cookiesFile != "" { if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile) args = append(args, "--cookies", c.cookiesFile)
} }
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
slog.Info("searching", "query", query, "source", source) slog.Info("searching", "query", query, "source", source)
out, err := c.run(args) out, err := c.run(args)
@@ -527,17 +519,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"}
}
}

View File

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

View File

@@ -6,13 +6,16 @@ import "os/exec"
type MediaType int type MediaType int
const ( const (
MediaTypeAudio MediaType = iota + 1 MediaTypeAsk MediaType = iota
MediaTypeAudio
MediaTypeVideo MediaTypeVideo
MediaTypeVideoAudio MediaTypeVideoAudio
) )
func (m MediaType) String() string { func (m MediaType) String() string {
switch m { switch m {
case MediaTypeAsk:
return "ask"
case MediaTypeAudio: case MediaTypeAudio:
return "audio" return "audio"
case MediaTypeVideo: case MediaTypeVideo:
@@ -50,9 +53,6 @@ type MediaInfo struct {
Duration float64 Duration float64
Formats []Format Formats []Format
Languages []string Languages []string
IsPlaylist bool
PlaylistCount int
PlaylistEntries []PlaylistEntry
EstimatedSize int64 EstimatedSize int64
} }
@@ -118,15 +118,16 @@ 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
Speed string Speed string
ETA string ETA string
FileSize int64 FileSize int64
MaxFileSize int64
FilePath string FilePath string
Title string Title string
ProgressPrefix string
QuotaApplied bool QuotaApplied bool
StderrLog string StderrLog string
Done chan struct{} Done chan struct{}
@@ -171,14 +172,6 @@ type DownloadRecord struct {
CreatedAt string CreatedAt string
} }
// PlaylistState tracks the user's current playlist selection flow.
type PlaylistState struct {
Entries []PlaylistEntry
Page int
PerPage int
Selected map[string]bool
}
// SearchState tracks the user's current search results flow. // SearchState tracks the user's current search results flow.
type SearchState struct { type SearchState struct {
Query string Query string

View File

@@ -10,7 +10,7 @@ func TestMediaTypeString(t *testing.T) {
{MediaTypeAudio, "audio"}, {MediaTypeAudio, "audio"},
{MediaTypeVideo, "video"}, {MediaTypeVideo, "video"},
{MediaTypeVideoAudio, "video_audio"}, {MediaTypeVideoAudio, "video_audio"},
{MediaType(0), "unknown"}, {MediaTypeAsk, "ask"},
{MediaType(99), "unknown"}, {MediaType(99), "unknown"},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@@ -23,21 +24,28 @@ type stepState struct {
URL string URL string
MediaInfo *domain.MediaInfo MediaInfo *domain.MediaInfo
Step int Step int
SelectedType string // "video", "audio", "ask", or "" when not yet chosen
MainFormatID string MainFormatID string
MainFormat *domain.Format MainFormat *domain.Format
SecondaryID string SecondaryID string
SecondaryFmt *domain.Format SecondaryFmt *domain.Format
Language string Language string
Container string
FormatIDs []string FormatIDs []string
PlaylistState *domain.PlaylistState
SearchState *domain.SearchState SearchState *domain.SearchState
CreatedAt time.Time
} }
const stepStateTTL = 30 * time.Minute
func (h *Handler) getUserStepState(chatID int64) *stepState { func (h *Handler) getUserStepState(chatID int64) *stepState {
h.stepStatesMu.Lock() h.stepStatesMu.Lock()
defer h.stepStatesMu.Unlock() defer h.stepStatesMu.Unlock()
return h.stepStates[chatID] 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) { func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
@@ -52,6 +60,25 @@ func (h *Handler) clearStepState(chatID int64) {
delete(h.stepStates, chatID) 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. // 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) { func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) {
url, ok := domain.ValidateURL(text) url, ok := domain.ValidateURL(text)
@@ -60,6 +87,13 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
return 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 { if job := h.getActiveJob(userID); job != nil {
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.") h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
return return
@@ -79,9 +113,7 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
{{Text: "Cancel", CallbackData: "pending_cancel"}}, {{Text: "Cancel", CallbackData: "pending_cancel"}},
}, },
} }
h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ h.sendWithKB(ctx, chatID, "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", kb)
ChatID: chatID, Text: "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", ReplyMarkup: kb,
})
return return
} }
h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle)) h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle))
@@ -106,19 +138,14 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
} }
} }
if info.IsPlaylist {
h.handlePlaylist(ctx, chatID, userID, url, info)
return
}
if len(info.Formats) == 0 { if len(info.Formats) == 0 {
h.sendText(ctx, chatID, "No formats available for this URL.") h.sendText(ctx, chatID, "No formats available for this URL.")
return return
} }
// Reject if user has already exhausted quota (limit of 0 = unlimited). // Reject if user has already exhausted quota (limit of 0 = unlimited).
q, err := h.Repo.GetOrCreateQuotas(userID) q, qErr := h.Repo.GetOrCreateQuotas(userID)
if err == nil { if qErr == nil {
q = h.resetQuotasIfNeeded(q) q = h.resetQuotasIfNeeded(q)
if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) || if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) ||
(q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) || (q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) ||
@@ -130,46 +157,175 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
} }
} }
h.sendMediaPreview(ctx, chatID, info)
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
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)
return
}
// Quality is Ask — show filtered format picker
ss := &stepState{ ss := &stepState{
URL: url, URL: url,
MediaInfo: info, MediaInfo: info,
Step: 0, Step: 0,
CreatedAt: time.Now(),
} }
h.sendMediaPreview(ctx, chatID, info)
// Build unified format list: video formats first, then audio
deduped := dl.DeduplicateResolutions(info.Formats)
var videoFmts, audioFmts []domain.Format
for _, f := range deduped {
if f.HasVideo {
videoFmts = append(videoFmts, f)
} else {
audioFmts = append(audioFmts, f)
}
}
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) h.setUserStepState(chatID, ss)
kb := formatKeyboard("format_", labels, ss.FormatIDs) h.showFormatsForType(ctx, chatID, userID, ss, prefs.DefaultMediaType.String())
return
}
ss := &stepState{
URL: url,
MediaInfo: info,
Step: 0,
CreatedAt: time.Now(),
}
h.setUserStepState(chatID, ss)
kb := mediaTypeKeyboard()
h.sendWithKB(ctx, chatID, "Select media type:", kb)
}
// autoSelectAndDownload picks the best matching format from preferences and
// skips the picker entirely.
func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo, prefs *domain.UserPreferences) {
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: "Processing..."})
if err != nil {
slog.Warn("send processing message failed", "error", err)
return
}
msgID := msg.ID
ss := &stepState{
URL: url,
MediaInfo: info,
Step: 0,
CreatedAt: time.Now(),
}
var mainID, secondaryID string
switch prefs.DefaultMediaType {
case domain.MediaTypeVideo:
mainID, secondaryID = autoSelectVideoFormat(info.Formats, prefs.DefaultVideoFormat)
case domain.MediaTypeAudio:
mainID = autoSelectAudioFormat(info.Formats, prefs.DefaultAudioFormat)
default:
mainID = "best"
}
if mainID == "" {
mainID = "best"
}
ss.MainFormatID = mainID
if mainID == "best" {
if info.IsAudioOnlyContent() {
ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true}
} else {
ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true}
}
} else {
for i := range info.Formats {
f := &info.Formats[i]
if f.ID == mainID {
ss.MainFormat = f
break
}
}
}
if secondaryID != "" {
ss.SecondaryID = secondaryID
for i := range info.Formats {
f := &info.Formats[i]
if f.ID == secondaryID {
ss.SecondaryFmt = f
break
}
}
}
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) h.sendWithKB(ctx, chatID, "Select format:", kb)
} }
// handleTypeSelection processes the initial type picker (Video/Audio/Ask).
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, mediaType string) {
ss := h.getUserStepState(chatID)
if ss == nil {
slog.Warn("nil stepState in handleTypeSelection", "chat_id", chatID, "user_id", userID)
return
}
ss.SelectedType = mediaType
ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType)
if err != nil {
h.editText(ctx, chatID, msgID, "No formats available for this type.", nil)
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.editText(ctx, chatID, msgID, "Select format:", &kb)
}
// handleFormatSelection processes a format pick from the unified list. // handleFormatSelection processes a format pick from the unified list.
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 +339,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 +410,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,27 +436,29 @@ 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)
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
} }
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 +485,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,11 +521,11 @@ 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,
FileSize: fileSize, FileSize: fileSize,
MaxFileSize: h.maxFileSize,
Done: make(chan struct{}), Done: make(chan struct{}),
} }
@@ -392,7 +545,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():
@@ -401,9 +553,13 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
defer func() { <-h.downloadSem }() defer func() { <-h.downloadSem }()
kb := progressKeyboard(0, "", "") kb := progressKeyboard(0, "", "")
progressHeader := "Downloading: " + job.Title
if job.ProgressPrefix != "" {
progressHeader = job.ProgressPrefix + "\n" + progressHeader
}
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: job.ChatID, ChatID: job.ChatID,
Text: fmt.Sprintf("Downloading: %s", job.Title), Text: progressHeader,
ReplyMarkup: &kb, ReplyMarkup: &kb,
}) })
if err != nil { if err != nil {
@@ -414,21 +570,35 @@ 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 != "" {
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 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)
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 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,25 +611,30 @@ 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()
delete(h.activeJobs, job.UserID)
h.activeJobsMu.Unlock()
return return
} }
if upd.Progress > 0 && lastProgress == 0 { if upd.Status == domain.StatusCompleted {
return
}
if lastProgress == 0 {
lastProgress = upd.Progress lastProgress = upd.Progress
} else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted {
continue
} else { } 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 lastProgress = upd.Progress
} }
@@ -469,34 +644,41 @@ loop:
} }
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA) kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
text := fmt.Sprintf("Downloading: %s", job.Title) text := "Downloading: " + job.Title
if job.ProgressPrefix != "" {
text = job.ProgressPrefix + "\n" + text
}
h.editText(ctx, job.ChatID, job.MessageID, text, &kb) h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
case <-ctx.Done(): case <-ctx.Done():
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) // Uses context.Background() for Telegram notifications so that cancellation
h.activeJobsMu.Unlock() // of the download context does not prevent the file from being delivered.
func (h *Handler) handleDownloadCompletion(ctx context.Context, job *domain.DownloadJob) {
h.cancelFuncsMu.Lock()
delete(h.cancelFuncs, job.UserID)
h.cancelFuncsMu.Unlock()
if job.Status == domain.StatusCompleted {
pattern := filepath.Join(h.downloadDir, job.ID+"_*") pattern := filepath.Join(h.downloadDir, job.ID+"_*")
files, err := filepath.Glob(pattern) files, err := filepath.Glob(pattern)
if err == nil && len(files) > 0 { 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] f := files[0]
fileInfo, err := os.Stat(f) fileInfo, err := os.Stat(f)
if err == nil { if err != nil {
h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed but file not found.", nil)
return
}
if fileInfo.Size() > h.maxFileSize { if fileInfo.Size() > h.maxFileSize {
h.editText(ctx, job.ChatID, job.MessageID, h.editText(context.Background(), job.ChatID, job.MessageID,
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil) fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
os.Remove(f) os.Remove(f)
return return
@@ -507,26 +689,21 @@ loop:
h.applyQuota(job.UserID, job.FileSize) h.applyQuota(job.UserID, job.FileSize)
} }
formatID := job.SelectedFormat.ID h.sendDocument(context.Background(), job.ChatID, f, job.Title)
h.sendDocument(ctx, job.ChatID, f, job.Title)
os.Remove(f) os.Remove(f)
_ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{ if err := h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
URL: job.URL, URL: job.URL,
Title: job.Title, Title: job.Title,
MediaType: job.MediaType.String(), MediaType: job.MediaType.String(),
FormatID: formatID, FormatID: job.SelectedFormat.ID,
Container: job.Container,
FileSize: job.FileSize, FileSize: job.FileSize,
Status: "completed", 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) h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed!", nil)
return
}
}
h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", 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,35 +756,39 @@ 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
} }
// If on the format list with a selected type, go back to type picker.
if ss.Step == 0 && ss.SelectedType != "" {
ss.SelectedType = ""
h.setUserStepState(chatID, ss)
kb := mediaTypeKeyboard()
h.editText(ctx, chatID, msgID, "Select media type:", &kb)
return
}
ss.Step-- ss.Step--
switch ss.Step { switch ss.Step {
case -1: case -1:
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, _ = buildFilteredFormatList(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)
@@ -644,7 +825,204 @@ 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
}
// buildFilteredFormatList returns format IDs filtered by media type for the type picker.
// When mediaType is "video", only formats with video are returned.
// When mediaType is "audio", only audio-only formats are returned.
// When empty or "ask", all formats are returned (current behavior).
func buildFilteredFormatList(formats []domain.Format, mediaType string) ([]string, error) {
deduped := dl.DeduplicateResolutions(formats)
ids := make([]string, 0, len(deduped)+1)
ids = append(ids, "best")
switch mediaType {
case "video":
for _, f := range deduped {
if f.HasVideo {
ids = append(ids, f.ID)
}
}
case "audio":
for _, f := range deduped {
if f.HasAudio && f.IsAudioOnly {
ids = append(ids, f.ID)
}
}
default:
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
}
// autoSelectVideoFormat finds the closest video format to the preferred resolution.
// Returns the main format ID and optionally a secondary audio ID if the best match
// is video-only. Prefers formats at or above the target resolution, falling back to
// the closest below. Returns ("best", "") if the preference is "best" or no match.
func autoSelectVideoFormat(formats []domain.Format, preferredID string) (mainID, secondaryID string) {
if preferredID == "" || preferredID == "best" {
return "best", ""
}
var targetHeight int
if n, err := fmt.Sscanf(preferredID, "%dp", &targetHeight); err != nil || n != 1 {
return "best", ""
}
var bestFmt *domain.Format
bestDiff := math.MaxInt32
for i := range formats {
f := &formats[i]
if !f.HasVideo || f.Height == 0 {
continue
}
diff := f.Height - targetHeight
if diff >= 0 && diff < bestDiff {
bestDiff = diff
bestFmt = f
}
}
if bestFmt == nil {
bestDiff = math.MaxInt32
for i := range formats {
f := &formats[i]
if !f.HasVideo || f.Height == 0 {
continue
}
diff := targetHeight - f.Height
if diff >= 0 && diff < bestDiff {
bestDiff = diff
bestFmt = f
}
}
}
if bestFmt == nil {
return "best", ""
}
mainID = bestFmt.ID
if bestFmt.HasVideo && !bestFmt.HasAudio {
for i := range formats {
f := &formats[i]
if f.HasAudio && f.IsAudioOnly {
secondaryID = f.ID
break
}
}
}
return
}
// autoSelectAudioFormat finds the closest audio format to the preferred bitrate.
// Returns the format ID, or "best" if the preference is "best" or no match.
func autoSelectAudioFormat(formats []domain.Format, preferredID string) string {
if preferredID == "" || preferredID == "best" {
return "best"
}
var targetBitrate int
if n, err := fmt.Sscanf(preferredID, "%dk", &targetBitrate); err != nil || n != 1 {
return "best"
}
var bestFmt *domain.Format
bestDiff := math.MaxInt32
for i := range formats {
f := &formats[i]
if !f.HasAudio || !f.IsAudioOnly {
continue
}
var bitrate int
if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 {
continue
}
diff := bitrate - targetBitrate
if diff >= 0 && diff < bestDiff {
bestDiff = diff
bestFmt = f
}
}
if bestFmt == nil {
bestDiff = math.MaxInt32
for i := range formats {
f := &formats[i]
if !f.HasAudio || !f.IsAudioOnly {
continue
}
var bitrate int
if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 {
continue
}
diff := targetBitrate - bitrate
if diff >= 0 && diff < bestDiff {
bestDiff = diff
bestFmt = f
}
}
}
if bestFmt == nil {
return "best"
}
return bestFmt.ID
}
// 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.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 != "" { if f.Resolution != "" {
label := f.Resolution label := f.Resolution
if f.Filesize > 0 { if f.Filesize > 0 {
@@ -652,6 +1030,13 @@ func formatLabelForDisplay(f domain.Format) string {
} }
return label return label
} }
if f.Height > 0 {
label := fmt.Sprintf("%dp", f.Height)
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
if f.Bitrate != "" { if f.Bitrate != "" {
label := f.Bitrate label := f.Bitrate
if f.Filesize > 0 { if f.Filesize > 0 {
@@ -659,19 +1044,12 @@ func formatLabelForDisplay(f domain.Format) string {
} }
return label return label
} }
// Audio-only with no bitrate: show format ID + extension
if f.IsAudioOnly {
label := f.ID label := f.ID
if f.Extension != "" { if f.Extension != "" {
label += " (" + f.Extension + ")" label += " (" + f.Extension + ")"
} }
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label return label
} }
return f.ID
}
// sendMediaPreview sends a thumbnail and metadata info before the selection flow. // sendMediaPreview sends a thumbnail and metadata info before the selection flow.
func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) { func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) {
@@ -721,7 +1099,14 @@ 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) tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
client := &http.Client{
Timeout: 30 * time.Second,
Transport: tr,
}
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)
} }

View File

@@ -273,6 +273,8 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
defer h.answerCb(ctx, cbID) defer h.answerCb(ctx, cbID)
switch { switch {
case strings.HasPrefix(data, "type_"):
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "format_"): case strings.HasPrefix(data, "format_"):
h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:]) h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:])
case strings.HasPrefix(data, "secondary_"): case strings.HasPrefix(data, "secondary_"):
@@ -280,23 +282,13 @@ 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_"):
h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:])
case strings.HasPrefix(data, "pl_entry_"):
h.handlePlaylistEntry(ctx, chatID, msgID, userID, data[9:])
case data == "pl_select_all":
h.handlePlaylistSelectAll(ctx, chatID, msgID, userID)
case data == "pl_confirm":
h.handlePlaylistConfirm(ctx, chatID, msgID, userID)
case strings.HasPrefix(data, "delaccount_"): case strings.HasPrefix(data, "delaccount_"):
if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 { if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 {
h.deleteAccountConfirm(ctx, chatID, msgID, uid) h.deleteAccountConfirm(ctx, chatID, msgID, uid)
} }
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_"):
@@ -466,21 +458,15 @@ func formatBytes(bytes int64) string {
if bytes < unit { if bytes < unit {
return strconv.FormatInt(bytes, 10) + "B" return strconv.FormatInt(bytes, 10) + "B"
} }
div, exp := int64(unit), 0 units := []string{"KB", "MB", "GB", "TB"}
for n := bytes / unit; n >= unit; n /= unit { size := float64(bytes)
div *= unit for _, u := range units {
exp++ size /= unit
if size < unit {
return fmt.Sprintf("%.1f%s", size, u)
} }
switch exp {
case 0:
return fmt.Sprintf("%.1fKB", float64(bytes)/float64(div))
case 1:
return fmt.Sprintf("%.1fMB", float64(bytes)/float64(div))
case 2:
return fmt.Sprintf("%.1fGB", float64(bytes)/float64(div))
default:
return fmt.Sprintf("%.1fTB", float64(bytes)/float64(div))
} }
return fmt.Sprintf("%.1fTB", size)
} }
func (h *Handler) generateJobID() string { func (h *Handler) generateJobID() string {
@@ -573,6 +559,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 +570,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.

View File

@@ -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
@@ -49,6 +169,209 @@ func TestFormatDuration(t *testing.T) {
} }
} }
func TestBuildFilteredFormatList(t *testing.T) {
formats := []domain.Format{
{ID: "1", HasVideo: true, HasAudio: false, Resolution: "1920x1080"},
{ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
{ID: "3", HasVideo: true, HasAudio: false, Resolution: "1280x720"},
{ID: "4", HasVideo: true, HasAudio: true, Resolution: "640x480"},
{ID: "5", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"},
}
t.Run("ask type returns all formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "ask")
if err != nil {
t.Fatalf("buildFilteredFormatList(ask) error: %v", err)
}
if ids[0] != "best" {
t.Errorf("first ID = %q, want best", ids[0])
}
// Should include all non-best IDs
expectedIDs := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true}
for _, id := range ids[1:] {
if !expectedIDs[id] {
t.Errorf("unexpected ID %q in ask list", id)
}
delete(expectedIDs, id)
}
if len(expectedIDs) > 0 {
t.Errorf("missing IDs in ask list: %v", expectedIDs)
}
})
t.Run("video type only returns video formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "video")
if err != nil {
t.Fatalf("buildFilteredFormatList(video) error: %v", err)
}
if ids[0] != "best" {
t.Errorf("first ID = %q, want best", ids[0])
}
for _, id := range ids[1:] {
if id == "2" || id == "5" {
t.Errorf("video list contains audio-only ID %q", id)
}
}
})
t.Run("audio type only returns audio-only formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "audio")
if err != nil {
t.Fatalf("buildFilteredFormatList(audio) error: %v", err)
}
if ids[0] != "best" {
t.Errorf("first ID = %q, want best", ids[0])
}
for _, id := range ids[1:] {
if id == "1" || id == "3" || id == "4" {
t.Errorf("audio list contains video ID %q", id)
}
}
})
t.Run("default (empty) returns all formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "")
if err != nil {
t.Fatalf("buildFilteredFormatList('') error: %v", err)
}
if len(ids) != 6 { // best + 5 formats
t.Errorf("got %d IDs, want 6", len(ids))
}
})
}
func TestAutoSelectVideoFormat(t *testing.T) {
formats := []domain.Format{
{ID: "137", HasVideo: true, HasAudio: false, Height: 1080, Width: 1920, Resolution: "1920x1080"},
{ID: "136", HasVideo: true, HasAudio: false, Height: 720, Width: 1280, Resolution: "1280x720"},
{ID: "135", HasVideo: true, HasAudio: false, Height: 480, Width: 854, Resolution: "854x480"},
{ID: "247", HasVideo: true, HasAudio: true, Height: 1080, Width: 1920, Resolution: "1920x1080"},
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
}
t.Run("best returns best, no secondary", func(t *testing.T) {
mainID, secondaryID := autoSelectVideoFormat(formats, "best")
if mainID != "best" {
t.Errorf("mainID = %q, want best", mainID)
}
if secondaryID != "" {
t.Errorf("secondaryID = %q, want empty", secondaryID)
}
})
t.Run("empty returns best", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "")
if mainID != "best" {
t.Errorf("mainID = %q, want best", mainID)
}
})
t.Run("exact match 1080p", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "1080p")
if mainID != "137" && mainID != "247" {
t.Errorf("mainID = %q, want 137 or 247", mainID)
}
})
t.Run("prefer above 360p", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "360p")
// 480p (135) is the closest above 360p; below is none
if mainID != "135" {
t.Errorf("mainID = %q, want 135 (480p is closest above 360p)", mainID)
}
})
t.Run("exact match 720p", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "720p")
// 720p (136) is an exact match
if mainID != "136" {
t.Errorf("mainID = %q, want 136 (exact match for 720p)", mainID)
}
})
t.Run("above 1080p takes 1080p as closest below", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "2160p")
// No 4K format, should fall back to 1080p (closest below)
if mainID != "137" && mainID != "247" {
t.Errorf("mainID = %q, want 137 or 247 (1080p is closest below 2160p)", mainID)
}
})
t.Run("video-only picks best audio secondary", func(t *testing.T) {
formats := []domain.Format{
{ID: "137", HasVideo: true, HasAudio: false, Height: 1080},
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
}
_, secondaryID := autoSelectVideoFormat(formats, "1080p")
if secondaryID != "140" {
t.Errorf("secondaryID = %q, want 140", secondaryID)
}
})
t.Run("no match returns best", func(t *testing.T) {
formats := []domain.Format{
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true},
}
mainID, _ := autoSelectVideoFormat(formats, "1080p")
if mainID != "best" {
t.Errorf("mainID = %q, want best", mainID)
}
})
}
func TestAutoSelectAudioFormat(t *testing.T) {
formats := []domain.Format{
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
{ID: "256", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "256k"},
{ID: "320", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"},
}
t.Run("best returns best", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "best")
if id != "best" {
t.Errorf("got %q, want best", id)
}
})
t.Run("empty returns best", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "")
if id != "best" {
t.Errorf("got %q, want best", id)
}
})
t.Run("exact match 256k", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "256k")
if id != "256" {
t.Errorf("got %q, want 256", id)
}
})
t.Run("prefer above 192k", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "192k")
if id != "256" {
t.Errorf("got %q, want 256 (closest above 192k)", id)
}
})
t.Run("above highest takes highest below", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "384k")
if id != "320" {
t.Errorf("got %q, want 320 (closest below 384k)", id)
}
})
t.Run("no match returns best", func(t *testing.T) {
formats := []domain.Format{
{ID: "1", HasVideo: true, HasAudio: true},
}
id := autoSelectAudioFormat(formats, "128k")
if id != "best" {
t.Errorf("got %q, want best", id)
}
})
}
func TestFormatLabelForDisplay(t *testing.T) { func TestFormatLabelForDisplay(t *testing.T) {
tests := []struct { tests := []struct {
f domain.Format f domain.Format
@@ -74,6 +397,18 @@ func TestFormatLabelForDisplay(t *testing.T) {
f: domain.Format{ID: "137"}, f: domain.Format{ID: "137"},
want: "137", want: "137",
}, },
{
f: domain.Format{IsAudioOnly: true, Bitrate: "128k", Filesize: 1048576, Resolution: "audio only"},
want: "128k (1.0MB)",
},
{
f: domain.Format{IsAudioOnly: true, Bitrate: "128k", Resolution: "audio only"},
want: "128k",
},
{
f: domain.Format{IsAudioOnly: true, ID: "140", Extension: "m4a", Filesize: 1048576, Resolution: "audio only"},
want: "140 (m4a) (1.0MB)",
},
} }
for _, tt := range tests { for _, tt := range tests {
got := formatLabelForDisplay(tt.f) got := formatLabelForDisplay(tt.f)

View File

@@ -2,11 +2,8 @@ package handler
import ( import (
"fmt" "fmt"
"strings"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"uptodownBot/internal/domain"
) )
func mainKeyboard() models.InlineKeyboardMarkup { func mainKeyboard() models.InlineKeyboardMarkup {
@@ -66,30 +63,11 @@ 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)
if speed != "" { if speed != "" {
progressText += fmt.Sprintf(" | %s/s", speed) progressText += fmt.Sprintf(" | %s", speed)
} }
if eta != "" { if eta != "" {
progressText += fmt.Sprintf(" | ETA %s", eta) progressText += fmt.Sprintf(" | ETA %s", eta)
@@ -102,85 +80,20 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku
} }
} }
// playlistNavKeyboard builds the paginated navigation for playlist selection. // mediaTypeKeyboard builds the initial type picker (Video/Audio/Ask).
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup { func mediaTypeKeyboard() models.InlineKeyboardMarkup {
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage return models.InlineKeyboardMarkup{
if totalPages < 1 { InlineKeyboard: [][]models.InlineKeyboardButton{
totalPages = 1 {
} {Text: "Video", CallbackData: "type_video"},
{Text: "Audio", CallbackData: "type_audio"},
// Build entry rows (2 per row) {Text: "Ask", CallbackData: "type_ask"},
rows := [][]models.InlineKeyboardButton{} },
start := state.Page * state.PerPage {
end := start + state.PerPage
if end > len(state.Entries) {
end = len(state.Entries)
}
for i := start; i < end; i += 2 {
row := []models.InlineKeyboardButton{}
// First entry
e := state.Entries[i]
prefix := " "
if state.Selected[e.ID] {
prefix = "> "
}
// Truncate title for button
title := e.Title
if len(title) > 30 {
title = title[:27] + "..."
}
row = append(row, models.InlineKeyboardButton{
Text: prefix + title, CallbackData: "pl_entry_" + e.ID,
})
// Second entry if available
if i+1 < end {
e2 := state.Entries[i+1]
prefix2 := " "
if state.Selected[e2.ID] {
prefix2 = "> "
}
title2 := e2.Title
if len(title2) > 30 {
title2 = title2[:27] + "..."
}
row = append(row, models.InlineKeyboardButton{
Text: prefix2 + title2, CallbackData: "pl_entry_" + e2.ID,
})
}
rows = append(rows, row)
}
// Navigation row
navRow := []models.InlineKeyboardButton{}
navRow = append(navRow, models.InlineKeyboardButton{
Text: "<", CallbackData: fmt.Sprintf("pl_page_%d", state.Page-1),
})
navRow = append(navRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("Page %d/%d", state.Page+1, totalPages), CallbackData: "noop",
})
navRow = append(navRow, models.InlineKeyboardButton{
Text: ">", CallbackData: fmt.Sprintf("pl_page_%d", state.Page+1),
})
rows = append(rows, navRow)
// Action row
actionRow := []models.InlineKeyboardButton{
{Text: "Select All", CallbackData: "pl_select_all"},
}
if selectedCount > 0 {
actionRow = append(actionRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("Confirm (%d)", selectedCount), CallbackData: "pl_confirm",
})
}
rows = append(rows, actionRow)
// Cancel
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Cancel", CallbackData: "cancel_dl"}, {Text: "Cancel", CallbackData: "cancel_dl"},
}) },
},
return models.InlineKeyboardMarkup{InlineKeyboard: rows} }
} }
// deleteConfirmKeyboard builds the confirmation prompt for account deletion. // deleteConfirmKeyboard builds the confirmation prompt for account deletion.

View File

@@ -1,204 +0,0 @@
package handler
import (
"context"
"fmt"
"strconv"
"uptodownBot/internal/domain"
)
func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo) {
state := &domain.PlaylistState{
Entries: info.PlaylistEntries,
Page: 0,
PerPage: 10,
Selected: make(map[string]bool),
}
ss := &stepState{
URL: url,
MediaInfo: info,
PlaylistState: state,
}
h.setUserStepState(chatID, ss)
h.sendMediaPreview(ctx, chatID, info)
kb := playlistNavKeyboard(state, 0)
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
}
func (h *Handler) getPlaylistState(chatID int64) *domain.PlaylistState {
ss := h.getUserStepState(chatID)
if ss == nil {
return nil
}
return ss.PlaylistState
}
func (h *Handler) handlePlaylistPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return
}
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if page < 0 || page >= totalPages {
return
}
state.Page = page
selectedCount := countSelected(state)
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistEntry(ctx context.Context, chatID int64, msgID int, userID int64, entryID string) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
state.Selected[entryID] = !state.Selected[entryID]
selectedCount := countSelected(state)
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistSelectAll(ctx context.Context, chatID int64, msgID int, userID int64) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
allSelected := true
for _, e := range state.Entries {
if !state.Selected[e.ID] {
allSelected = false
break
}
}
for _, e := range state.Entries {
state.Selected[e.ID] = !allSelected
}
selectedCount := 0
if !allSelected {
selectedCount = len(state.Entries)
}
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
var selected []domain.PlaylistEntry
for _, e := range state.Entries {
if state.Selected[e.ID] {
selected = append(selected, e)
}
}
if len(selected) == 0 {
h.editText(ctx, chatID, msgID, "No videos selected.", nil)
return
}
h.clearStepState(chatID)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos sequentially...", len(selected)), nil)
for i, entry := range selected {
h.sendText(ctx, chatID, fmt.Sprintf("Downloading (%d/%d): %s", i+1, len(selected), entry.Title))
info, err := h.YtDlp.GetMediaInfo(entry.URL)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("Failed: %s", err.Error()))
continue
}
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
if prefErr != nil {
prefs = &domain.UserPreferences{
DefaultMediaType: domain.MediaTypeVideoAudio,
DefaultAudioFormat: "best",
DefaultVideoFormat: "best",
DefaultAudioContainer: "mp3",
DefaultVideoContainer: "mp4",
}
}
formatID := prefs.DefaultVideoFormat
container := prefs.DefaultVideoContainer
if prefs.DefaultMediaType == domain.MediaTypeAudio {
formatID = prefs.DefaultAudioFormat
container = prefs.DefaultAudioContainer
}
format := &domain.Format{ID: formatID}
for _, f := range info.Formats {
if f.ID == formatID {
format = &f
break
}
}
dlCtx, dlCancel := context.WithCancel(context.Background())
job := &domain.DownloadJob{
ID: h.generateJobID(),
UserID: userID,
ChatID: chatID,
URL: entry.URL,
MediaType: prefs.DefaultMediaType,
SelectedFormat: format,
Container: container,
Language: prefs.DefaultLanguage,
Status: domain.StatusDownloading,
Title: entry.Title,
FileSize: format.Filesize,
Done: make(chan struct{}),
}
h.setActiveJob(userID, job)
h.setCancelFunc(userID, dlCancel)
h.runDownload(dlCtx, job)
// runDownload is synchronous — it blocks until the download completes.
// The Done channel is closed inside runDownload when it exits.
// No busy-polling needed.
}
h.sendText(ctx, chatID, "All playlist downloads completed.")
}
func countSelected(state *domain.PlaylistState) int {
count := 0
for _, s := range state.Selected {
if s {
count++
}
}
return count
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
@@ -39,7 +40,7 @@ func (h *Handler) handleSearchQuery(ctx context.Context, chatID int64, text stri
PerPage: 5, PerPage: 5,
Source: source, Source: source,
} }
ss := &stepState{SearchState: state} ss := &stepState{SearchState: state, CreatedAt: time.Now()}
h.setUserStepState(chatID, ss) h.setUserStepState(chatID, ss)
kb := searchResultsKeyboard(state) kb := searchResultsKeyboard(state)

View File

@@ -14,32 +14,32 @@ 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"
}
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"
} }
mediaTypeLabel := "Ask"
switch prefs.DefaultMediaType {
case domain.MediaTypeAudio:
mediaTypeLabel = "Audio"
case domain.MediaTypeVideo:
mediaTypeLabel = "Video"
}
rows := [][]models.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
{ {
{Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"}, {Text: fmt.Sprintf("Media Type: %s", mediaTypeLabel), CallbackData: "settings_mediatype"},
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
}, },
{ {
{Text: fmt.Sprintf("Container: %s/%s", audioCont, videoCont), CallbackData: "settings_container"}, {Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"},
{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"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}},
@@ -73,6 +73,9 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
} }
switch setting { switch setting {
case "mediatype":
text, kb := h.buildMediaTypePicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "quality": case "quality":
text, kb := h.buildQualityAudioVideoPicker(prefs) text, kb := h.buildQualityAudioVideoPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
@@ -82,15 +85,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)
@@ -107,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{
@@ -125,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{
@@ -147,56 +145,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{
@@ -225,6 +173,31 @@ func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, mo
return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
options := []struct {
mt domain.MediaType
label string
}{
{domain.MediaTypeAsk, "Ask"},
{domain.MediaTypeVideo, "Video"},
{domain.MediaTypeAudio, "Audio"},
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range options {
label := opt.label
if opt.mt == prefs.DefaultMediaType {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "settings_set_mediatype_" + opt.mt.String()},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) { func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
parts := strings.SplitN(setting, "_", 2) parts := strings.SplitN(setting, "_", 2)
if len(parts) < 2 { if len(parts) < 2 {
@@ -240,18 +213,28 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
changed := false changed := false
switch action { switch action {
case "mediatype":
switch value {
case "ask":
prefs.DefaultMediaType = domain.MediaTypeAsk
case "audio":
prefs.DefaultMediaType = domain.MediaTypeAudio
case "video":
prefs.DefaultMediaType = domain.MediaTypeVideo
}
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 "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

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) VALUES (?)", "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
@@ -90,14 +90,18 @@ func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
p.DefaultMediaType = domain.MediaTypeAudio p.DefaultMediaType = domain.MediaTypeAudio
case "video": case "video":
p.DefaultMediaType = domain.MediaTypeVideo p.DefaultMediaType = domain.MediaTypeVideo
case "ask":
p.DefaultMediaType = domain.MediaTypeAsk
default: default:
p.DefaultMediaType = domain.MediaTypeVideoAudio p.DefaultMediaType = domain.MediaTypeAsk
} }
return &p, nil return &p, nil
} }
func mediaTypeToString(mt domain.MediaType) string { func mediaTypeToString(mt domain.MediaType) string {
switch mt { switch mt {
case domain.MediaTypeAsk:
return "ask"
case domain.MediaTypeAudio: case domain.MediaTypeAudio:
return "audio" return "audio"
case domain.MediaTypeVideo: case domain.MediaTypeVideo:

View File

@@ -59,8 +59,8 @@ func TestPreferences(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetOrCreatePreferences() failed: %v", err) t.Fatalf("GetOrCreatePreferences() failed: %v", err)
} }
if prefs.DefaultMediaType != domain.MediaTypeVideoAudio { if prefs.DefaultMediaType != domain.MediaTypeAsk {
t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType) t.Errorf("default media type = %v, want Ask", prefs.DefaultMediaType)
} }
prefs.DefaultMediaType = domain.MediaTypeAudio prefs.DefaultMediaType = domain.MediaTypeAudio