Compare commits

...

19 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
dfe946a41b refactor: split mutex, add download queue, wire context cancellation
All checks were successful
CI / build (push) Successful in 53s
- Split single sync.Mutex into per-map mutexes (activeJobs, stepStates,
  rateLimiter, pendingInput, cancelFuncs) to reduce contention
- Add download semaphore (channel-buffered, 3 concurrent) as worker pool
- Add cancelFuncs map with context cancellation wired into download
  goroutines for clean shutdown
- Replace playlist busy-poll (500ms sleep loop) with synchronous
  runDownload + Done channel for completion tracking
- Use full UUID for job IDs instead of 8-char prefix
- Download thumbnail URL to temp file before sending (InputFileUpload)
- Change MAX_FILE_SIZE_MB default from 2000 to 50
- Clean up cancel funcs after normal download completion
2026-06-25 23:23:19 +03:30
3290bb55c0 feat: add in-memory media info cache and YouTube search feature
- Add in-memory LRU cache (200 entries, 30 min TTL) to yt-dlp Client for
  GetMediaInfo and GetPlaylistInfo results, avoiding redundant yt-dlp calls
  for the same URL within a session
- Add YouTube (/search) and YouTube Music (/music) search with paginated
  results (5 per page), user selects a result to start the download flow
- Add SearchState type, new search.go handler, search results keyboard with
  < > navigation and New Search button
- Add Search button to main menu keyboard
- Update help text with new commands
2026-06-25 22:58:20 +03:30
23 changed files with 1667 additions and 826 deletions

View File

@@ -3,7 +3,7 @@ HTTP_PROXY=
HTTPS_PROXY= HTTPS_PROXY=
DB_PATH=data/uptodown.db DB_PATH=data/uptodown.db
DOWNLOAD_DIR=/tmp/uptodown DOWNLOAD_DIR=/tmp/uptodown
MAX_FILE_SIZE_MB=2000 MAX_FILE_SIZE_MB=50
COOKIES_FILE= COOKIES_FILE=
TZ=UTC TZ=UTC
BOT_ALLOWED_USERS= BOT_ALLOWED_USERS=

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
@@ -41,7 +45,7 @@ Copy `.env.example` to `.env` and configure:
| `HTTPS_PROXY` | empty | Outbound HTTPS proxy | | `HTTPS_PROXY` | empty | Outbound HTTPS proxy |
| `DB_PATH` | `data/uptodown.db` | SQLite database path | | `DB_PATH` | `data/uptodown.db` | SQLite database path |
| `DOWNLOAD_DIR` | `/tmp/uptodown` | Temporary download directory | | `DOWNLOAD_DIR` | `/tmp/uptodown` | Temporary download directory |
| `MAX_FILE_SIZE_MB` | `2000` | Maximum upload file size in MB | | `MAX_FILE_SIZE_MB` | `50` | Maximum upload file size in MB |
| `COOKIES_FILE` | empty | Path to cookies.txt for auth | | `COOKIES_FILE` | empty | Path to cookies.txt for auth |
| `TZ` | `UTC` | Timezone | | `TZ` | `UTC` | Timezone |
| `BOT_ALLOWED_USERS` | empty | Comma-separated allowed user IDs | | `BOT_ALLOWED_USERS` | empty | Comma-separated allowed user IDs |
@@ -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

@@ -29,7 +29,7 @@ func main() {
token := os.Getenv("BOT_TOKEN") token := os.Getenv("BOT_TOKEN")
dbPath := getEnvDefault("DB_PATH", "data/uptodown.db") dbPath := getEnvDefault("DB_PATH", "data/uptodown.db")
downloadDir := getEnvDefault("DOWNLOAD_DIR", "/tmp/uptodown") downloadDir := getEnvDefault("DOWNLOAD_DIR", "/tmp/uptodown")
maxFileSizeMB := getEnvIntDefault("MAX_FILE_SIZE_MB", 2000) maxFileSizeMB := getEnvIntDefault("MAX_FILE_SIZE_MB", 50)
cookiesFile := os.Getenv("COOKIES_FILE") cookiesFile := os.Getenv("COOKIES_FILE")
if token == "" { if token == "" {

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:])
u.eta = etaStr if parenPos := strings.Index(etaStr, "("); parenPos > 0 {
etaStr = strings.TrimSpace(etaStr[:parenPos])
}
if !strings.EqualFold(etaStr, "unknown") && etaStr != "N/A" {
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,11 +5,14 @@ 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"
"syscall" "syscall"
"time" "time"
@@ -20,6 +23,37 @@ import (
type Client struct { type Client struct {
binaryPath string binaryPath string
cookiesFile string cookiesFile string
proxyURL string
cache map[string]*cachedMediaInfo
cacheMu sync.Mutex
}
type cachedMediaInfo struct {
info *domain.MediaInfo
ts time.Time
}
const cacheTTL = 30 * time.Minute
const cacheMaxEntries = 200
func (c *Client) getCached(url string) *domain.MediaInfo {
c.cacheMu.Lock()
defer c.cacheMu.Unlock()
entry, ok := c.cache[url]
if !ok || time.Since(entry.ts) > cacheTTL {
delete(c.cache, url)
return nil
}
return entry.info
}
func (c *Client) setCache(url string, info *domain.MediaInfo) {
c.cacheMu.Lock()
defer c.cacheMu.Unlock()
if len(c.cache) >= cacheMaxEntries {
c.cache = make(map[string]*cachedMediaInfo)
}
c.cache[url] = &cachedMediaInfo{info: info, ts: time.Now()}
} }
// NewClient creates a new yt-dlp client. It checks for the binary. // NewClient creates a new yt-dlp client. It checks for the binary.
@@ -29,7 +63,21 @@ 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)
return &Client{binaryPath: path, cookiesFile: cookiesFile}, nil
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{
binaryPath: path,
cookiesFile: cookiesFile,
proxyURL: proxyURL,
cache: make(map[string]*cachedMediaInfo),
}, nil
} }
type ytdlpFormat struct { type ytdlpFormat struct {
@@ -70,6 +118,11 @@ type ytdlpPlaylistEntry struct {
// GetMediaInfo fetches format information for a given URL. // GetMediaInfo fetches format information for a given URL.
func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) { func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
if cached := c.getCached(url); cached != nil {
slog.Debug("cache hit", "url", url)
return cached, nil
}
args := []string{ args := []string{
"-J", "--no-download", "-J", "--no-download",
"--no-warnings", "--no-warnings",
@@ -78,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)
@@ -95,18 +151,11 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
} }
mi := &domain.MediaInfo{ mi := &domain.MediaInfo{
URL: url, URL: url,
Title: info.Title, Title: info.Title,
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)
return mi, nil
} }
langSet := make(map[string]bool) langSet := make(map[string]bool)
@@ -114,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)
} }
@@ -124,41 +173,7 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
} }
mi.EstimatedSize = maxSize mi.EstimatedSize = maxSize
return mi, nil c.setCache(url, mi)
}
// GetPlaylistInfo fetches a flat playlist listing (fast, no format detail).
func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
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),
}
return mi, nil return mi, nil
} }
@@ -217,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)
}
} }
} }
@@ -246,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)
@@ -268,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)
@@ -292,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() {
@@ -358,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 {
@@ -376,6 +413,39 @@ func (c *Client) SearchYoutube(query string) (url, title string, err error) {
return info.Entries[0].URL, info.Entries[0].Title, nil return info.Entries[0].URL, info.Entries[0].Title, nil
} }
// Search does a yt-dlp search and returns entries.
func (c *Client) Search(query, source string, limit int) ([]domain.PlaylistEntry, error) {
searchPrefix := "ytsearch"
if source == "ytmusic" {
searchPrefix = "ytmusicsearch"
}
args := []string{
"--flat-playlist", "-J", "--no-download",
"--no-warnings",
fmt.Sprintf("%s%d:%s", searchPrefix, limit, query),
}
if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile)
}
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
slog.Info("searching", "query", query, "source", source)
out, err := c.run(args)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
var info ytdlpInfo
if err := json.Unmarshal(out, &info); err != nil {
return nil, fmt.Errorf("parse search result: %w", err)
}
return c.parsePlaylistEntries(info.Entries), nil
}
func (c *Client) run(args []string) ([]byte, error) { func (c *Client) run(args []string) ([]byte, error) {
cmd := exec.Command(c.binaryPath, args...) cmd := exec.Command(c.binaryPath, args...)
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -449,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,7 +6,7 @@ const (
RateLimitInterval = 1 * time.Second RateLimitInterval = 1 * time.Second
ProgressThreshold = 5.0 ProgressThreshold = 5.0
MaxFileSizeEnvKey = "MAX_FILE_SIZE_MB" MaxFileSizeEnvKey = "MAX_FILE_SIZE_MB"
DefaultMaxFileSize = 2000 DefaultMaxFileSize = 50
DefaultDailyQuotaMB = 100 DefaultDailyQuotaMB = 100
DefaultWeeklyQuotaMB = 500 DefaultWeeklyQuotaMB = 500
DefaultMonthlyQuotaMB = 2000 DefaultMonthlyQuotaMB = 2000

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:
@@ -43,17 +46,14 @@ type Format struct {
// MediaInfo holds the full information about a downloadable media item. // MediaInfo holds the full information about a downloadable media item.
type MediaInfo struct { type MediaInfo struct {
URL string URL string
Title string Title string
Uploader string Uploader string
Thumbnail string Thumbnail string
Duration float64 Duration float64
Formats []Format Formats []Format
Languages []string Languages []string
IsPlaylist bool EstimatedSize int64
PlaylistCount int
PlaylistEntries []PlaylistEntry
EstimatedSize int64
} }
// IsAudioOnlyContent returns true when no format carries a video stream. // IsAudioOnlyContent returns true when no format carries a video stream.
@@ -118,17 +118,19 @@ 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{}
CancelCh chan struct{} CancelCh chan struct{}
Cmd *exec.Cmd Cmd *exec.Cmd
} }
@@ -170,10 +172,11 @@ type DownloadRecord struct {
CreatedAt string CreatedAt string
} }
// PlaylistState tracks the user's current playlist selection flow. // SearchState tracks the user's current search results flow.
type PlaylistState struct { type SearchState struct {
Entries []PlaylistEntry Query string
Page int Entries []PlaylistEntry
PerPage int Page int
Selected map[string]bool PerPage int
Source string // "youtube" or "ytmusic"
} }

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 {

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,9 @@ import (
type PendingKind string type PendingKind string
const ( const (
PendingURL PendingKind = "url" PendingURL PendingKind = "url"
PendingDRM PendingKind = "drm" PendingDRM PendingKind = "drm"
PendingSearch PendingKind = "search"
) )
type PendingInput struct { type PendingInput struct {
@@ -35,18 +36,29 @@ type PendingInput struct {
Data map[string]string Data map[string]string
} }
const maxConcurrentDownloads = 3
type Handler struct { type Handler struct {
Bot *tgbot.Bot Bot *tgbot.Bot
Repo repo.Repository Repo repo.Repository
YtDlp *dl.Client YtDlp *dl.Client
AllowedUsers map[int64]bool AllowedUsers map[int64]bool
activeJobs map[int64]*domain.DownloadJob activeJobs map[int64]*domain.DownloadJob
stepStates map[int64]*stepState stepStates map[int64]*stepState
lastMsgTime map[int64]time.Time lastMsgTime map[int64]time.Time
pendingInput map[int64]*PendingInput pendingInput map[int64]*PendingInput
mu sync.Mutex cancelFuncs map[int64]context.CancelFunc
downloadDir string
maxFileSize int64 activeJobsMu sync.Mutex
stepStatesMu sync.Mutex
rateLimiterMu sync.Mutex
pendingInputMu sync.Mutex
cancelFuncsMu sync.Mutex
downloadDir string
maxFileSize int64
downloadSem chan struct{}
} }
func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed map[int64]bool, downloadDir string, maxFileSize int64) *Handler { func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed map[int64]bool, downloadDir string, maxFileSize int64) *Handler {
@@ -59,8 +71,10 @@ func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed
stepStates: make(map[int64]*stepState), stepStates: make(map[int64]*stepState),
lastMsgTime: make(map[int64]time.Time), lastMsgTime: make(map[int64]time.Time),
pendingInput: make(map[int64]*PendingInput), pendingInput: make(map[int64]*PendingInput),
cancelFuncs: make(map[int64]context.CancelFunc),
downloadDir: downloadDir, downloadDir: downloadDir,
maxFileSize: maxFileSize, maxFileSize: maxFileSize,
downloadSem: make(chan struct{}, maxConcurrentDownloads),
} }
go h.periodicCleanup() go h.periodicCleanup()
return h return h
@@ -69,27 +83,29 @@ func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed
func (h *Handler) periodicCleanup() { func (h *Handler) periodicCleanup() {
for { for {
time.Sleep(domain.CleanupInterval) time.Sleep(domain.CleanupInterval)
h.mu.Lock()
// Clean rate limit entries func() {
cutoff := time.Now().Add(-domain.RateLimitInterval * 2) h.rateLimiterMu.Lock()
for uid, t := range h.lastMsgTime { defer h.rateLimiterMu.Unlock()
if t.Before(cutoff) { cutoff := time.Now().Add(-domain.RateLimitInterval * 2)
delete(h.lastMsgTime, uid) for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
} }
} }()
// Clean stale pending inputs func() {
pendingCutoff := time.Now().Add(-5 * time.Minute) h.pendingInputMu.Lock()
for chatID, pi := range h.pendingInput { defer h.pendingInputMu.Unlock()
if pi.CreatedAt.Before(pendingCutoff) { pendingCutoff := time.Now().Add(-5 * time.Minute)
delete(h.pendingInput, chatID) for chatID, pi := range h.pendingInput {
if pi.CreatedAt.Before(pendingCutoff) {
delete(h.pendingInput, chatID)
}
} }
} }()
h.mu.Unlock()
// Clean old temp files (>1h)
h.cleanOldTempFiles() h.cleanOldTempFiles()
} }
} }
@@ -115,8 +131,8 @@ func (h *Handler) cleanOldTempFiles() {
} }
func (h *Handler) isRateLimited(userID int64) bool { func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock() h.rateLimiterMu.Lock()
defer h.mu.Unlock() defer h.rateLimiterMu.Unlock()
now := time.Now() now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < domain.RateLimitInterval { if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < domain.RateLimitInterval {
return true return true
@@ -146,19 +162,19 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
if msg.Text[0] == '/' { if msg.Text[0] == '/' {
h.mu.Lock() h.pendingInputMu.Lock()
delete(h.pendingInput, msg.Chat.ID) delete(h.pendingInput, msg.Chat.ID)
h.mu.Unlock() h.pendingInputMu.Unlock()
h.routeCommand(ctx, msg, userID) h.routeCommand(ctx, msg, userID)
return return
} }
h.mu.Lock() h.pendingInputMu.Lock()
pi, hasPI := h.pendingInput[msg.Chat.ID] pi, hasPI := h.pendingInput[msg.Chat.ID]
if hasPI { if hasPI {
delete(h.pendingInput, msg.Chat.ID) delete(h.pendingInput, msg.Chat.ID)
} }
h.mu.Unlock() h.pendingInputMu.Unlock()
if hasPI { if hasPI {
h.processPendingInput(ctx, msg, userID, pi) h.processPendingInput(ctx, msg, userID, pi)
@@ -185,6 +201,10 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, userID
h.handleHistory(ctx, msg, userID) h.handleHistory(ctx, msg, userID)
case "/download": case "/download":
h.handleDownloadCommand(ctx, msg, userID) h.handleDownloadCommand(ctx, msg, userID)
case "/search":
h.handleSearchCommand(ctx, msg, userID)
case "/music":
h.handleMusicSearchCommand(ctx, msg, userID)
default: default:
h.sendText(ctx, msg.Chat.ID, "Unknown command") h.sendText(ctx, msg.Chat.ID, "Unknown command")
} }
@@ -207,9 +227,9 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
} }
slog.Info("callback", "chat_id", chatID, "data", cb.Data) slog.Info("callback", "chat_id", chatID, "data", cb.Data)
h.mu.Lock() h.pendingInputMu.Lock()
delete(h.pendingInput, chatID) delete(h.pendingInput, chatID)
h.mu.Unlock() h.pendingInputMu.Unlock()
msgID := cb.Message.Message.ID msgID := cb.Message.Message.ID
data := cb.Data data := cb.Data
@@ -226,6 +246,9 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
case data == "download": case data == "download":
h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID) h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID)
h.answerCb(ctx, cb.ID) h.answerCb(ctx, cb.ID)
case data == "search":
h.promptForInput(ctx, chatID, msgID, userID, PendingSearch, "Enter a YouTube search query:", map[string]string{"source": "youtube"})
h.answerCb(ctx, cb.ID)
case data == "settings": case data == "settings":
h.settingsCallback(ctx, chatID, msgID, cb.ID, userID) h.settingsCallback(ctx, chatID, msgID, cb.ID, userID)
case data == "help": case data == "help":
@@ -250,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_"):
@@ -257,34 +282,26 @@ 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_"):
h.handleSettingsSet(ctx, chatID, msgID, userID, data[13:]) h.handleSettingsSet(ctx, chatID, msgID, userID, data[13:])
case strings.HasPrefix(data, "settings_"): case strings.HasPrefix(data, "settings_"):
h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:]) h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:])
case strings.HasPrefix(data, "search_"):
h.handleSearchCallback(ctx, chatID, msgID, userID, data[7:])
} }
} }
func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) { func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) {
h.mu.Lock() h.pendingInputMu.Lock()
h.pendingInput[chatID] = &PendingInput{ h.pendingInput[chatID] = &PendingInput{
Kind: kind, Kind: kind,
UserID: userID, UserID: userID,
@@ -292,7 +309,7 @@ func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userI
MsgID: msgID, MsgID: msgID,
Data: data, Data: data,
} }
h.mu.Unlock() h.pendingInputMu.Unlock()
} }
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, userID int64, pi *PendingInput) { func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, userID int64, pi *PendingInput) {
@@ -302,6 +319,12 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message,
h.handleURLInput(ctx, msg.Chat.ID, text, userID) h.handleURLInput(ctx, msg.Chat.ID, text, userID)
case PendingDRM: case PendingDRM:
h.handleDRMSearch(ctx, msg.Chat.ID, text, userID) h.handleDRMSearch(ctx, msg.Chat.ID, text, userID)
case PendingSearch:
source := "youtube"
if pi.Data != nil && pi.Data["source"] != "" {
source = pi.Data["source"]
}
h.handleSearchQuery(ctx, msg.Chat.ID, text, userID, source)
default: default:
h.sendText(ctx, msg.Chat.ID, "Unknown input type") h.sendText(ctx, msg.Chat.ID, "Unknown input type")
} }
@@ -396,50 +419,58 @@ func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID
} }
func (h *Handler) clearActiveJob(userID int64) { func (h *Handler) clearActiveJob(userID int64) {
h.mu.Lock() h.activeJobsMu.Lock()
defer h.mu.Unlock() job, ok := h.activeJobs[userID]
if job, ok := h.activeJobs[userID]; ok { if ok {
dl.CancelDownload(job) dl.CancelDownload(job)
delete(h.activeJobs, userID) delete(h.activeJobs, userID)
} }
h.activeJobsMu.Unlock()
h.cancelFuncsMu.Lock()
if cancel, ok := h.cancelFuncs[userID]; ok {
cancel()
delete(h.cancelFuncs, userID)
}
h.cancelFuncsMu.Unlock()
} }
func (h *Handler) getActiveJob(userID int64) *domain.DownloadJob { func (h *Handler) getActiveJob(userID int64) *domain.DownloadJob {
h.mu.Lock() h.activeJobsMu.Lock()
defer h.mu.Unlock() defer h.activeJobsMu.Unlock()
return h.activeJobs[userID] return h.activeJobs[userID]
} }
func (h *Handler) setActiveJob(userID int64, job *domain.DownloadJob) { func (h *Handler) setActiveJob(userID int64, job *domain.DownloadJob) {
h.mu.Lock() h.activeJobsMu.Lock()
defer h.mu.Unlock() defer h.activeJobsMu.Unlock()
h.activeJobs[userID] = job h.activeJobs[userID] = job
} }
func (h *Handler) setCancelFunc(userID int64, cancel context.CancelFunc) {
h.cancelFuncsMu.Lock()
defer h.cancelFuncsMu.Unlock()
h.cancelFuncs[userID] = cancel
}
func formatBytes(bytes int64) string { func formatBytes(bytes int64) string {
const unit = 1024 const unit = 1024
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 {
switch exp { return fmt.Sprintf("%.1f%s", size, u)
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 {
return uuid.NewString()[:8] return uuid.NewString()
} }
// handleStart shows the main menu and cleans up any stale state. // handleStart shows the main menu and cleans up any stale state.
@@ -457,6 +488,8 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
/settings - Open settings /settings - Open settings
/help - Show this help /help - Show this help
/history - View download history /history - View download history
/search - Search YouTube
/music - Search YouTube Music
Just send me a URL to start downloading.` Just send me a URL to start downloading.`
h.sendText(ctx, msg.Chat.ID, text) h.sendText(ctx, msg.Chat.ID, text)
@@ -526,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)
@@ -536,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 {
@@ -14,6 +11,7 @@ func mainKeyboard() models.InlineKeyboardMarkup {
InlineKeyboard: [][]models.InlineKeyboardButton{ InlineKeyboard: [][]models.InlineKeyboardButton{
{ {
{Text: "Download", CallbackData: "download"}, {Text: "Download", CallbackData: "download"},
{Text: "Search", CallbackData: "search"},
{Text: "Settings", CallbackData: "settings"}, {Text: "Settings", CallbackData: "settings"},
}, },
{ {
@@ -65,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)
@@ -101,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"},
{Text: "Ask", CallbackData: "type_ask"},
},
{
{Text: "Cancel", CallbackData: "cancel_dl"},
},
},
} }
// Build entry rows (2 per row)
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"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
// deleteConfirmKeyboard builds the confirmation prompt for account deletion. // deleteConfirmKeyboard builds the confirmation prompt for account deletion.

View File

@@ -1,205 +0,0 @@
package handler
import (
"context"
"fmt"
"strconv"
"time"
"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
}
}
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,
}
h.setActiveJob(userID, job)
h.runDownload(context.Background(), job)
// Wait for active job to complete
for {
j := h.getActiveJob(userID)
if j == nil || j.Status == domain.StatusCompleted || j.Status == domain.StatusFailed || j.Status == domain.StatusCancelled {
break
}
time.Sleep(500 * time.Millisecond)
}
}
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
}

137
internal/handler/search.go Normal file
View File

@@ -0,0 +1,137 @@
package handler
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/domain"
)
func (h *Handler) handleSearchCommand(ctx context.Context, msg *models.Message, userID int64) {
h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingSearch,
"Enter a YouTube search query:", map[string]string{"source": "youtube"})
}
func (h *Handler) handleMusicSearchCommand(ctx context.Context, msg *models.Message, userID int64) {
h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingSearch,
"Enter a YouTube Music search query:", map[string]string{"source": "ytmusic"})
}
func (h *Handler) handleSearchQuery(ctx context.Context, chatID int64, text string, userID int64, source string) {
results, err := h.YtDlp.Search(text, source, 10)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("Search failed: %s", err.Error()))
return
}
if len(results) == 0 {
h.sendText(ctx, chatID, "No results found.")
return
}
state := &domain.SearchState{
Query: text,
Entries: results,
Page: 0,
PerPage: 5,
Source: source,
}
ss := &stepState{SearchState: state, CreatedAt: time.Now()}
h.setUserStepState(chatID, ss)
kb := searchResultsKeyboard(state)
h.sendWithKB(ctx, chatID, fmt.Sprintf("Search results for '%s':", text), kb)
}
func (h *Handler) handleSearchCallback(ctx context.Context, chatID int64, msgID int, userID int64, data string) {
switch {
case data == "refresh":
ss := h.getUserStepState(chatID)
if ss == nil || ss.SearchState == nil {
return
}
h.handleSearchQuery(ctx, chatID, ss.SearchState.Query, userID, ss.SearchState.Source)
case strings.HasPrefix(data, "page_"):
h.handleSearchPage(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "select_"):
h.handleSearchSelect(ctx, chatID, msgID, userID, data[7:])
}
}
func (h *Handler) handleSearchPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
ss := h.getUserStepState(chatID)
if ss == nil || ss.SearchState == nil {
return
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return
}
totalPages := (len(ss.SearchState.Entries) + ss.SearchState.PerPage - 1) / ss.SearchState.PerPage
if page < 0 || page >= totalPages {
return
}
ss.SearchState.Page = page
h.setUserStepState(chatID, ss)
kb := searchResultsKeyboard(ss.SearchState)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Search results for '%s':", ss.SearchState.Query), &kb)
}
func (h *Handler) handleSearchSelect(ctx context.Context, chatID int64, msgID int, userID int64, entryURL string) {
h.clearStepState(chatID)
h.editText(ctx, chatID, msgID, "Fetching media info...", nil)
h.handleURLInput(ctx, chatID, entryURL, userID)
}
func searchResultsKeyboard(state *domain.SearchState) models.InlineKeyboardMarkup {
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if totalPages < 1 {
totalPages = 1
}
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++ {
e := state.Entries[i]
title := e.Title
if len(title) > 40 {
title = title[:37] + "..."
}
label := title
if e.Duration > 0 {
label += fmt.Sprintf(" (%s)", formatDuration(e.Duration))
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "search_select_" + e.URL},
})
}
navRow := []models.InlineKeyboardButton{}
navRow = append(navRow, models.InlineKeyboardButton{
Text: "<", CallbackData: fmt.Sprintf("search_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("search_page_%d", state.Page+1),
})
rows = append(rows, navRow)
rows = append(rows, []models.InlineKeyboardButton{
{Text: "New Search", CallbackData: "search_refresh"},
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}

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