Compare commits

..

32 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
66a8cd1128 fix: resolve back step, audio labels, progress parsing, file size pre-check, and stale job cleanup
All checks were successful
CI / build (push) Successful in 36s
- Fix handleBackStep to redirect to download prompt when step state is nil
  (previously silent no-op after container selection cleared the state)
- Improve formatLabel for audio-only entries without bitrate: use ID + extension
  instead of generic 'audio', fixing duplicate entries in format list
- Add yt-dlp --progress-template for reliable JSON progress parsing across all
  sites (fixes 0.0% progress on sites like xnxx.com where [download] lines
  are absent); keep regex parser as fallback for older yt-dlp versions
- Add file size pre-check in handleContainerSelection before download starts
  (previously checked only after download completed, wasting bandwidth)
- Clear stale active jobs and step state on /start for a clean slate
2026-06-25 22:45:25 +03:30
8bcb0094b2 feat: treat quota limit of 0 as unlimited
All checks were successful
CI / build (push) Successful in 41s
Pre-check now skips periods where limit is 0, allowing users to have
unlimited quota by setting daily_limit_mb/weekly_limit_mb/monthly_limit_mb
to 0 in the database.
2026-06-25 22:09:36 +03:30
f39e4e6fb0 fix: quota pre-check used max format size, blocked before format selection
Changed pre-check to only reject when used >= limit (already exhausted),
not when the max format size alone would exceed the limit. The actual
quota is applied at 50% progress using the selected format's size.
2026-06-25 22:08:05 +03:30
f8f9d99e69 fix: set PYTHONUNBUFFERED=1 to force real-time progress output
All checks were successful
CI / build (push) Successful in 58s
yt-dlp is a Python script. When stderr is a pipe (not a TTY), Python
buffers output, causing progress updates to arrive in large chunks or
only at the end. Setting PYTHONUNBUFFERED=1 forces line-buffered output,
giving real-time progress.
2026-06-25 16:24:07 +03:30
e8120b9529 fix: show first progress update immediately instead of waiting for 5%
- Always show the first nonzero progress update so users don't see 0% for
  extended periods on slow downloads or fragment-based downloads
- Keep 5% throttle for subsequent updates to avoid excessive API calls
- Add readProgress summary logging (lines, matches, stderr length)
- Fix progressKeyboard not being updated until progress >= 5%
2026-06-25 16:23:07 +03:30
cd7aaeebc2 feat: capture yt-dlp stderr on download failures for debugging
Add StderrLog field to DownloadJob and collect all stderr lines during
download. Log the captured stderr output when a download fails to help
diagnose exit code 2 errors (geo-restrictions, auth, etc.).
2026-06-25 16:12:03 +03:30
8408b17ca0 feat: add DRM search-by-name fallback for individual Spotify tracks
When SearchYoutube with the Spotify URL fails for individual tracks,
offer a 'Search on YouTube' button that lets the user enter a search
query (artist/song name) to find the content on YouTube.
2026-06-25 16:10:37 +03:30
0c27822871 fix: 'best' format on audio-only sites (SoundCloud) now shows audio containers
All checks were successful
CI / build (push) Successful in 51s
- When content is audio-only (IsAudioOnlyContent), 'best' format is marked as
  IsAudioOnly so determineMediaType returns MediaTypeAudio
- Previously 'best' was hardcoded with HasVideo:true, causing video containers
  (mp4/mkv) to be shown instead of audio containers (mp3/m4a/opus)
2026-06-25 16:06:57 +03:30
785e20bf94 refactor: rename Format to Quality, add container Audio/Video sub-pickers
- Rename 'Format' button to 'Quality' in settings (audio_quality/video_quality)
- Add DefaultAudioContainer and DefaultVideoContainer to UserPreferences
- Container settings now has Audio/Video sub-pickers (mp3/m4a/opus vs mp4/mkv/webm)
- Add migration 003 for new container columns
- Update repo layer and playlist flow for new fields
- Update back routing: back_quality, back_container
2026-06-25 16:03:03 +03:30
e08ed77884 refactor: settings format picker redesign with Audio/Video sub-pickers, fix delete routing
All checks were successful
CI / build (push) Successful in 48s
- Replace Type/Quality settings with single Format button -> Audio/Video sub-pickers
- Each sub-picker shows format options (bitrates for audio, resolutions for video)
- Replace DefaultQuality with DefaultAudioFormat/DefaultVideoFormat in UserPreferences
- Add DB migration 002 for new preference columns
- Update repo layer SQL queries for new schema
- Update playlist flow to use new preference fields
- Fix missing deleteaccount and delaccount_ callback routing
- Add back_format routing for format picker navigation
2026-06-25 15:55:59 +03:30
30a99ac2fd fix: progress stuck at 0%, container not respected, audio display, Spotify DRM fallback
- Remove --no-progress from yt-dlp args that suppressed all progress output
- Add --merge-output-format when FormatString is set (container was ignored for combined formats)
- Improve formatLabelForDisplay for audio-only formats missing bitrate (show ID+extension)
- Detect DRM errors (Spotify) and fall back to YouTube Music search via ytsearch1:
2026-06-25 15:50:43 +03:30
e65ade6c3c refactor: redesign format selection with unified list and independent audio/video picking
All checks were successful
CI / build (push) Successful in 52s
- Replace type/quality selection flow with unified format list (video+audio combined)
- Add independent audio/video format picking: video-only formats prompt for audio track,
  audio-only formats offer optional video addition (skip when content is audio-only)
- Fix formatKeyboard to use format IDs as callback data instead of display labels
- Remove dead mediaTypeKeyboard function
- Fix double-close panic in readProgress (caller is sole owner of updates channel)
- Add FormatString field to DownloadJob for composite format specs (e.g. '137+140')
- Change quota calculation from sum of all format sizes to max single format size
- Fix 'Delete all data' button to use danger style with proper text
- Clean up routePrefixedCallback: remove type_/quality_, add format_/secondary_ routes
- Fix back navigation to rebuild format IDs list
2026-06-25 15:37:47 +03:30
7de79b50e1 chore: add .gitea CI/CD workflows
All checks were successful
CI / build (push) Successful in 1m4s
2026-06-25 15:11:23 +03:30
26 changed files with 2214 additions and 941 deletions

View File

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

36
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
env:
# temporary: proxy required due to firewall blocks
HTTP_PROXY: http://192.168.100.2:10808
HTTPS_PROXY: http://192.168.100.2:10808
steps:
- name: Install system dependencies (Node.js, Git)
run: |
apt-get update
apt-get install -y nodejs git ca-certificates
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Check formatting
run: test -z "$(gofmt -d .)" || exit 1
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test
run: go test ./...

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

View File

@@ -5,20 +5,24 @@ Telegram bot for downloading media from any site yt-dlp supports.
## Features
- 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
- 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
- Playlist support with paginated entry selection (10 per page, select all, confirm with count)
- Download progress updates (every 5% with speed and ETA)
- Multi-step format selection: type picker (Video/Audio/Ask), quality, secondary format (video+audio combine), language
- Auto-select: skip all pickers when both media type AND quality are set in preferences (quality "Ask" shows a filtered picker)
- Audio remuxed to m4a (Telegram-friendly), video remuxed to mp4
- Download progress updates (with speed and ETA)
- Cancel downloads at any time (terminates yt-dlp process)
- Per-user quota system (daily, weekly, monthly)
- User preferences (default quality, container, language)
- Download history
- Configurable file size limit
- Cookies support for age-restricted content
- Per-user quota system (daily, weekly, monthly; 0 = unlimited)
- User preferences (default media type, audio/video quality with "Ask" option, language)
- Download history with pagination
- Configurable file size limit (default 50 MB)
- Cookies support for age-restricted and authenticated content
- Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
- Webhook and polling modes
- SQLite database for persistence
- yt-dlp plugin support — `hanime-plugin` bundled in Docker image
## Commands
@@ -41,7 +45,7 @@ Copy `.env.example` to `.env` and configure:
| `HTTPS_PROXY` | empty | Outbound HTTPS proxy |
| `DB_PATH` | `data/uptodown.db` | SQLite database path |
| `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 |
| `TZ` | `UTC` | Timezone |
| `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_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
### Polling mode (default)
@@ -73,6 +88,46 @@ make run-dev
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
```bash

View File

@@ -29,7 +29,7 @@ func main() {
token := os.Getenv("BOT_TOKEN")
dbPath := getEnvDefault("DB_PATH", "data/uptodown.db")
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")
if token == "" {

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"os"
"time"
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")
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 != "" {
slog.Info("starting HTTPS webhook", "addr", listenAddr)
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)
os.Exit(1)
}
@@ -34,7 +51,7 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) {
} else {
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
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)
os.Exit(1)
}

View File

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

View File

@@ -3,6 +3,7 @@ package dl
import (
"bufio"
"io"
"log/slog"
"regexp"
"strconv"
"strings"
@@ -10,7 +11,8 @@ import (
"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\|\s*([\d.]+)%\|\s*(.*?)\s*\|(.*)$`)
type progressUpdate struct {
pct float64
@@ -18,15 +20,41 @@ type progressUpdate struct {
eta string
}
// parseProgressTemplate parses a DLPROG-prefixed line from yt-dlp's --progress-template.
func parseProgressTemplate(line string) *progressUpdate {
m := progressTemplateRE.FindStringSubmatch(line)
if m == nil {
return nil
}
pct, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return nil
}
u := &progressUpdate{pct: pct}
speed := strings.TrimSpace(m[2])
eta := strings.TrimSpace(m[3])
if speed != "" && !strings.EqualFold(speed, "unknown") && speed != "N/A" {
u.speed = speed
}
if eta != "" && !strings.EqualFold(eta, "unknown") && eta != "N/A" {
u.eta = eta
}
return u
}
// parseProgressLine parses a single yt-dlp stderr progress line.
// Returns nil if the line doesn't contain progress info.
func parseProgressLine(line string) *progressUpdate {
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "[download]") {
if line == "" {
return nil
}
if u := parseProgressTemplate(line); u != nil {
return u
}
if !strings.HasPrefix(line, "[download]") {
return nil
}
// Try to extract the simple percentage from the default format:
// [download] 45.2% of 123.45MiB at 2.34MiB/s ETA 00:45
m := progressRE.FindStringSubmatch(line)
if m == nil {
return nil
@@ -38,41 +66,57 @@ func parseProgressLine(line string) *progressUpdate {
u := &progressUpdate{pct: pct}
// Extract speed and ETA from remaining parts
rest := line
if idx := strings.Index(rest, "%"); idx >= 0 {
rest = rest[idx+1:]
}
// Look for "at <speed>"
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 {
u.speed = strings.TrimSpace(afterAt[:spaceIdx])
speedVal := afterAt[:spaceIdx]
if !strings.EqualFold(speedVal, "unknown") && speedVal != "N/A" {
u.speed = speedVal
}
rest = afterAt[spaceIdx:]
} else {
if !strings.EqualFold(afterAt, "unknown") && afterAt != "N/A" {
u.speed = afterAt
}
rest = ""
}
}
// Look for "ETA <duration>"
if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 {
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
}
}
return u
}
// readProgress reads yt-dlp stderr and sends progress updates to the channel.
func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
defer close(updates)
scanner := bufio.NewScanner(stderr)
// readProgress reads yt-dlp output and sends progress updates to the channel.
// The caller is responsible for closing the updates channel.
func readProgress(reader io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
scanner := bufio.NewScanner(reader)
lineCount := 0
matchCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
parsed := parseProgressLine(line)
if parsed == nil {
continue
}
matchCount++
job.Progress = parsed.pct
job.Speed = parsed.speed
job.ETA = parsed.eta
updates <- job
}
slog.Info("readProgress finished", "url", job.URL, "lines", lineCount, "matches", matchCount)
}

View File

@@ -67,6 +67,27 @@ func TestParseProgressLine(t *testing.T) {
eta: "00:02",
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",
hasRes: false,
@@ -100,6 +121,93 @@ func TestParseProgressLine(t *testing.T) {
}
}
func TestParseProgressTemplate(t *testing.T) {
tests := []struct {
line string
pct float64
speed string
eta string
hasRes bool
}{
{
line: "DLPROG| 45.2%| 2.34MiB/s|00:45",
pct: 45.2,
speed: "2.34MiB/s",
eta: "00:45",
hasRes: true,
},
{
line: "DLPROG|100.0%| 5.00MiB/s|00:00",
pct: 100.0,
speed: "5.00MiB/s",
eta: "00:00",
hasRes: true,
},
{
line: "DLPROG| 0.0%||",
pct: 0.0,
speed: "",
eta: "",
hasRes: true,
},
{
line: "not a progress line",
hasRes: false,
},
{
line: "",
hasRes: false,
},
{
line: "[download] 45.2% of 123.45MiB at 2.34MiB/s ETA 00:45",
hasRes: false,
},
{
line: "DLPROG| 50.0%| Unknown|Unknown",
pct: 50.0,
speed: "",
eta: "",
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 {
got := parseProgressTemplate(tt.line)
if !tt.hasRes {
if got != nil {
t.Errorf("parseProgressTemplate(%q) = %+v, want nil", tt.line, got)
}
continue
}
if got == nil {
t.Errorf("parseProgressTemplate(%q) = nil, want result", tt.line)
continue
}
if math.Abs(got.pct-tt.pct) > 0.01 {
t.Errorf("parseProgressTemplate(%q).pct = %f, want %f", tt.line, got.pct, tt.pct)
}
if got.speed != tt.speed {
t.Errorf("parseProgressTemplate(%q).speed = %q, want %q", tt.line, got.speed, tt.speed)
}
if got.eta != tt.eta {
t.Errorf("parseProgressTemplate(%q).eta = %q, want %q", tt.line, got.eta, tt.eta)
}
}
}
func TestParseProgressLineEdgeCases(t *testing.T) {
tests := []struct {
line string
@@ -109,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] 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 ~ 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 {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -5,11 +5,14 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -20,6 +23,37 @@ import (
type Client struct {
binaryPath 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.
@@ -29,7 +63,21 @@ func NewClient(cookiesFile string) (*Client, error) {
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
}
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 {
@@ -70,6 +118,11 @@ type ytdlpPlaylistEntry struct {
// GetMediaInfo fetches format information for a given URL.
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{
"-J", "--no-download",
"--no-warnings",
@@ -78,6 +131,9 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile)
}
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
slog.Info("fetching media info", "url", url)
out, err := c.run(args)
@@ -100,63 +156,24 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
Uploader: info.Uploader,
Thumbnail: info.Thumbnail,
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)
var maxSize int64
for _, f := range info.Formats {
ff := c.convertFormat(f)
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
mi.Languages = append(mi.Languages, ff.Language)
}
if ff.Filesize > 0 {
mi.EstimatedSize += ff.Filesize
if ff.Filesize > maxSize {
maxSize = ff.Filesize
}
}
mi.EstimatedSize = maxSize
return mi, nil
}
// 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),
}
c.setCache(url, mi)
return mi, nil
}
@@ -215,20 +232,17 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
formatID := job.SelectedFormat.ID
var args []string
// Build format string for merge
// Use format string for the download — yt-dlp handles container choice
if job.FormatString != "" {
args = append(args, "-f", job.FormatString)
} else {
switch job.MediaType {
case domain.MediaTypeAudio:
args = append(args, "-f", formatID, "-x")
if job.Container != "" {
args = append(args, "--audio-format", job.Container)
}
case domain.MediaTypeVideo:
args = append(args, "-f", formatID)
case domain.MediaTypeVideoAudio:
// Download best video + best audio that match
args = append(args, "-f", formatID+"+bestaudio/best")
if job.Container != "" {
args = append(args, "--merge-output-format", job.Container)
}
}
@@ -236,25 +250,51 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
args = append(args, "--output", outputPath)
// Progress reporting
args = append(args, "--newline", "--no-progress")
// Remux to Telegram-friendly containers: mp4 for video, m4a for audio-only.
if job.MediaType == domain.MediaTypeAudio {
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
if job.Language != "" {
args = append(args, "--sub-langs", job.Language)
args = append(args, "--audio-multi-streams")
}
// Cookies
if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile)
}
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
args = append(args, job.URL)
slog.Info("starting download", "url", job.URL, "format", formatID)
cmd := exec.Command(c.binaryPath, args...)
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()
if err != nil {
@@ -280,10 +320,16 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
progressDone := make(chan struct{})
go func() {
readProgress(stderr, updates, job)
readProgress(stdout, updates, job)
close(progressDone)
}()
// Capture stderr (warnings, errors) for diagnostics
go func() {
slurp, _ := io.ReadAll(stderr)
job.StderrLog = string(slurp)
}()
// Wait for either completion or cancellation
done := make(chan error, 1)
go func() {
@@ -305,7 +351,7 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
<-progressDone
if err != nil {
job.Status = domain.StatusFailed
slog.Error("download failed", "url", job.URL, "error", err)
slog.Error("download failed", "url", job.URL, "error", err, "stderr", job.StderrLog)
} else {
job.Status = domain.StatusCompleted
}
@@ -330,6 +376,76 @@ func CancelDownload(job *domain.DownloadJob) {
}
}
// ytdlpSearchResult holds a single flat search result from yt-dlp.
type ytdlpSearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
}
// SearchYoutube searches YouTube via yt-dlp ytsearch and returns the first result URL and title.
func (c *Client) SearchYoutube(query string) (url, title string, err error) {
args := []string{
"--flat-playlist", "-J", "--no-download",
"--no-warnings",
"ytsearch1:" + query,
}
if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile)
}
if c.proxyURL != "" {
args = append(args, "--proxy", c.proxyURL)
}
out, err := c.run(args)
if err != nil {
return "", "", fmt.Errorf("youtube search failed: %w", err)
}
var info struct {
Entries []ytdlpSearchResult `json:"entries"`
}
if err := json.Unmarshal(out, &info); err != nil {
return "", "", fmt.Errorf("parse search result: %w", err)
}
if len(info.Entries) == 0 {
return "", "", fmt.Errorf("no results found")
}
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) {
cmd := exec.Command(c.binaryPath, args...)
var stderr bytes.Buffer
@@ -390,7 +506,10 @@ func formatLabel(f domain.Format) string {
if f.Bitrate != "" {
return f.Bitrate
}
return "audio"
if f.Extension != "" {
return f.ID + " (" + f.Extension + ")"
}
return f.ID
}
if f.Resolution != "" {
return f.Resolution
@@ -400,17 +519,3 @@ func formatLabel(f domain.Format) string {
}
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,44 +52,14 @@ 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) {
tests := []struct {
f domain.Format
want string
}{
{domain.Format{IsAudioOnly: true, Bitrate: "128k"}, "128k"},
{domain.Format{IsAudioOnly: true}, "audio"},
{domain.Format{ID: "140", Extension: "m4a", IsAudioOnly: true}, "140 (m4a)"},
{domain.Format{ID: "251", IsAudioOnly: true}, "251"},
{domain.Format{Resolution: "1920x1080", Height: 1080}, "1920x1080"},
{domain.Format{Height: 720}, "720p"},
{domain.Format{ID: "137"}, "137"},

View File

@@ -6,7 +6,7 @@ const (
RateLimitInterval = 1 * time.Second
ProgressThreshold = 5.0
MaxFileSizeEnvKey = "MAX_FILE_SIZE_MB"
DefaultMaxFileSize = 2000
DefaultMaxFileSize = 50
DefaultDailyQuotaMB = 100
DefaultWeeklyQuotaMB = 500
DefaultMonthlyQuotaMB = 2000

View File

@@ -6,13 +6,16 @@ import "os/exec"
type MediaType int
const (
MediaTypeAudio MediaType = iota + 1
MediaTypeAsk MediaType = iota
MediaTypeAudio
MediaTypeVideo
MediaTypeVideoAudio
)
func (m MediaType) String() string {
switch m {
case MediaTypeAsk:
return "ask"
case MediaTypeAudio:
return "audio"
case MediaTypeVideo:
@@ -50,9 +53,6 @@ type MediaInfo struct {
Duration float64
Formats []Format
Languages []string
IsPlaylist bool
PlaylistCount int
PlaylistEntries []PlaylistEntry
EstimatedSize int64
}
@@ -117,16 +117,20 @@ type DownloadJob struct {
URL string
MediaType MediaType
SelectedFormat *Format
Container string
FormatString string
Language string
Status DownloadStatus
Progress float64
Speed string
ETA string
FileSize int64
MaxFileSize int64
FilePath string
Title string
ProgressPrefix string
QuotaApplied bool
StderrLog string
Done chan struct{}
CancelCh chan struct{}
Cmd *exec.Cmd
}
@@ -134,8 +138,10 @@ type DownloadJob struct {
// UserPreferences holds per-user default preferences.
type UserPreferences struct {
DefaultMediaType MediaType
DefaultQuality string
DefaultContainer string
DefaultAudioFormat string
DefaultVideoFormat string
DefaultAudioContainer string
DefaultVideoContainer string
DefaultLanguage string
}
@@ -166,10 +172,11 @@ type DownloadRecord struct {
CreatedAt string
}
// PlaylistState tracks the user's current playlist selection flow.
type PlaylistState struct {
// SearchState tracks the user's current search results flow.
type SearchState struct {
Query string
Entries []PlaylistEntry
Page int
PerPage int
Selected map[string]bool
Source string // "youtube" or "ytmusic"
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ type PendingKind string
const (
PendingURL PendingKind = "url"
PendingDRM PendingKind = "drm"
PendingSearch PendingKind = "search"
)
type PendingInput struct {
@@ -34,18 +36,29 @@ type PendingInput struct {
Data map[string]string
}
const maxConcurrentDownloads = 3
type Handler struct {
Bot *tgbot.Bot
Repo repo.Repository
YtDlp *dl.Client
AllowedUsers map[int64]bool
activeJobs map[int64]*domain.DownloadJob
stepStates map[int64]*stepState
lastMsgTime map[int64]time.Time
pendingInput map[int64]*PendingInput
mu sync.Mutex
cancelFuncs map[int64]context.CancelFunc
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 {
@@ -58,8 +71,10 @@ func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed
stepStates: make(map[int64]*stepState),
lastMsgTime: make(map[int64]time.Time),
pendingInput: make(map[int64]*PendingInput),
cancelFuncs: make(map[int64]context.CancelFunc),
downloadDir: downloadDir,
maxFileSize: maxFileSize,
downloadSem: make(chan struct{}, maxConcurrentDownloads),
}
go h.periodicCleanup()
return h
@@ -68,27 +83,29 @@ func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed
func (h *Handler) periodicCleanup() {
for {
time.Sleep(domain.CleanupInterval)
h.mu.Lock()
// Clean rate limit entries
func() {
h.rateLimiterMu.Lock()
defer h.rateLimiterMu.Unlock()
cutoff := time.Now().Add(-domain.RateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
}()
// Clean stale pending inputs
func() {
h.pendingInputMu.Lock()
defer h.pendingInputMu.Unlock()
pendingCutoff := time.Now().Add(-5 * time.Minute)
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()
}
}
@@ -114,8 +131,8 @@ func (h *Handler) cleanOldTempFiles() {
}
func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
h.rateLimiterMu.Lock()
defer h.rateLimiterMu.Unlock()
now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < domain.RateLimitInterval {
return true
@@ -145,19 +162,19 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
if msg.Text[0] == '/' {
h.mu.Lock()
h.pendingInputMu.Lock()
delete(h.pendingInput, msg.Chat.ID)
h.mu.Unlock()
h.pendingInputMu.Unlock()
h.routeCommand(ctx, msg, userID)
return
}
h.mu.Lock()
h.pendingInputMu.Lock()
pi, hasPI := h.pendingInput[msg.Chat.ID]
if hasPI {
delete(h.pendingInput, msg.Chat.ID)
}
h.mu.Unlock()
h.pendingInputMu.Unlock()
if hasPI {
h.processPendingInput(ctx, msg, userID, pi)
@@ -184,6 +201,10 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, userID
h.handleHistory(ctx, msg, userID)
case "/download":
h.handleDownloadCommand(ctx, msg, userID)
case "/search":
h.handleSearchCommand(ctx, msg, userID)
case "/music":
h.handleMusicSearchCommand(ctx, msg, userID)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
@@ -206,9 +227,9 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
}
slog.Info("callback", "chat_id", chatID, "data", cb.Data)
h.mu.Lock()
h.pendingInputMu.Lock()
delete(h.pendingInput, chatID)
h.mu.Unlock()
h.pendingInputMu.Unlock()
msgID := cb.Message.Message.ID
data := cb.Data
@@ -225,13 +246,21 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
case data == "download":
h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID)
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":
h.settingsCallback(ctx, chatID, msgID, cb.ID, userID)
case data == "help":
h.handleHelp(ctx, cb.Message.Message)
h.answerCb(ctx, cb.ID)
case data == "history":
h.handleHistory(ctx, cb.Message.Message, userID)
h.historyCallback(ctx, chatID, msgID, cb.ID, userID)
case data == "deleteaccount":
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
h.answerCb(ctx, cb.ID)
case data == "drm_search":
h.promptForInput(ctx, chatID, msgID, userID, PendingDRM, "Enter a search query to find this content on YouTube:", nil)
h.answerCb(ctx, cb.ID)
case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
@@ -246,35 +275,33 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
switch {
case strings.HasPrefix(data, "type_"):
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "quality_"):
h.handleQualitySelection(ctx, chatID, msgID, userID, data[8:])
case strings.HasPrefix(data, "format_"):
h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:])
case strings.HasPrefix(data, "secondary_"):
h.handleSecondarySelection(ctx, chatID, msgID, userID, data[10:])
case strings.HasPrefix(data, "lang_"):
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "container_"):
h.handleContainerSelection(ctx, chatID, msgID, userID, data[10:])
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)
// container selection was removed — yt-dlp handles it
case strings.HasPrefix(data, "delaccount_"):
if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 {
h.deleteAccountConfirm(ctx, chatID, msgID, uid)
}
case data == "back_quality":
h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality")
case data == "back_settings":
h.backToSettings(ctx, chatID, msgID, userID)
case data == "delaccount_prompt":
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
case strings.HasPrefix(data, "delaccount_"):
h.deleteAccountConfirm(ctx, chatID, msgID, userID)
case strings.HasPrefix(data, "settings_"):
h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:])
case strings.HasPrefix(data, "settings_set_"):
h.handleSettingsSet(ctx, chatID, msgID, userID, data[13:])
case strings.HasPrefix(data, "settings_"):
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) {
h.mu.Lock()
h.pendingInputMu.Lock()
h.pendingInput[chatID] = &PendingInput{
Kind: kind,
UserID: userID,
@@ -282,7 +309,7 @@ func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userI
MsgID: msgID,
Data: data,
}
h.mu.Unlock()
h.pendingInputMu.Unlock()
}
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, userID int64, pi *PendingInput) {
@@ -290,11 +317,29 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message,
switch pi.Kind {
case PendingURL:
h.handleURLInput(ctx, msg.Chat.ID, text, userID)
case PendingDRM:
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:
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
}
}
func (h *Handler) handleDRMSearch(ctx context.Context, chatID int64, text string, userID int64) {
youtubeURL, ytTitle, err := h.YtDlp.SearchYoutube(text)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("No results found for '%s'. Try a different search query or send a YouTube URL directly.", text))
return
}
h.sendText(ctx, chatID, fmt.Sprintf("Found on YouTube: %s", ytTitle))
h.handleURLInput(ctx, chatID, youtubeURL, userID)
}
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
@@ -367,60 +412,72 @@ func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string)
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
h.clearActiveJob(userID)
h.editText(ctx, chatID, msgID, "Main Menu:", nil)
h.sendWithKB(ctx, chatID, "Main Menu:", mainKeyboard())
h.editText(ctx, chatID, msgID, "Main Menu:", &models.InlineKeyboardMarkup{
InlineKeyboard: mainKeyboard().InlineKeyboard,
})
h.clearStepState(chatID)
}
func (h *Handler) clearActiveJob(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
if job, ok := h.activeJobs[userID]; ok {
h.activeJobsMu.Lock()
job, ok := h.activeJobs[userID]
if ok {
dl.CancelDownload(job)
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 {
h.mu.Lock()
defer h.mu.Unlock()
h.activeJobsMu.Lock()
defer h.activeJobsMu.Unlock()
return h.activeJobs[userID]
}
func (h *Handler) setActiveJob(userID int64, job *domain.DownloadJob) {
h.mu.Lock()
defer h.mu.Unlock()
h.activeJobsMu.Lock()
defer h.activeJobsMu.Unlock()
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 {
const unit = 1024
if bytes < unit {
return strconv.FormatInt(bytes, 10) + "B"
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
units := []string{"KB", "MB", "GB", "TB"}
size := float64(bytes)
for _, u := range units {
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 {
return uuid.NewString()[:8]
return uuid.NewString()
}
// handleStart shows the main menu.
// handleStart shows the main menu and cleans up any stale state.
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, userID int64) {
slog.Info("start", "user_id", userID, "chat_id", msg.Chat.ID)
h.clearActiveJob(userID)
h.clearStepState(msg.Chat.ID)
h.sendWithKB(ctx, msg.Chat.ID, "Main Menu:", mainKeyboard())
}
@@ -431,6 +488,8 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
/settings - Open settings
/help - Show this help
/history - View download history
/search - Search YouTube
/music - Search YouTube Music
Just send me a URL to start downloading.`
h.sendText(ctx, msg.Chat.ID, text)
@@ -451,7 +510,7 @@ func (h *Handler) handleSettings(ctx context.Context, msg *models.Message, userI
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, userID int64) {
records, err := h.Repo.GetHistory(userID, 10, 0)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading history")
h.sendText(ctx, msg.Chat.ID, "Error loading history.")
return
}
if len(records) == 0 {
@@ -465,10 +524,42 @@ func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, userID
h.sendText(ctx, msg.Chat.ID, text)
}
// historyCallback handles the history button from the main menu.
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
records, err := h.Repo.GetHistory(userID, 10, 0)
if err != nil {
h.editText(ctx, chatID, msgID, "Error loading history.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
})
return
}
if len(records) == 0 {
h.editText(ctx, chatID, msgID, "No download history yet.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
})
return
}
text := "Recent downloads:\n"
for i, r := range records {
text += fmt.Sprintf("%d. %s (%s)\n", i+1, r.Title, formatBytes(r.FileSize))
}
h.editText(ctx, chatID, msgID, text, &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
})
}
// applyQuota adds file size to the user's quota counters.
func (h *Handler) applyQuota(userID int64, fileSize int64) {
q, err := h.Repo.GetOrCreateQuotas(userID)
if err != nil {
slog.Error("get quotas for apply", "user_id", userID, "error", err)
return
}
q = h.resetQuotasIfNeeded(q)
@@ -479,7 +570,9 @@ func (h *Handler) applyQuota(userID int64, fileSize int64) {
q.DailyUsedMB += sizeMB
q.WeeklyUsedMB += 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.

View File

@@ -6,6 +6,126 @@ import (
"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) {
tests := []struct {
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) {
tests := []struct {
f domain.Format
@@ -74,6 +397,18 @@ func TestFormatLabelForDisplay(t *testing.T) {
f: domain.Format{ID: "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 {
got := formatLabelForDisplay(tt.f)

View File

@@ -2,11 +2,8 @@ package handler
import (
"fmt"
"strings"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/domain"
)
func mainKeyboard() models.InlineKeyboardMarkup {
@@ -14,6 +11,7 @@ func mainKeyboard() models.InlineKeyboardMarkup {
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Download", CallbackData: "download"},
{Text: "Search", CallbackData: "search"},
{Text: "Settings", CallbackData: "settings"},
},
{
@@ -40,27 +38,14 @@ func settingsBackKeyboard() models.InlineKeyboardMarkup {
}
}
// mediaTypeKeyboard builds the three-button row for media type selection + Cancel.
func mediaTypeKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Audio Only", CallbackData: "type_audio"},
{Text: "Video Only", CallbackData: "type_video"},
{Text: "Video+Audio", CallbackData: "type_both"},
},
{{Text: "Cancel", CallbackData: "cancel_dl"}},
},
}
}
// formatKeyboard builds a keyboard from a slice of format labels.
func formatKeyboard(prefix string, labels []string) models.InlineKeyboardMarkup {
// formatKeyboard builds a 2-column inline keyboard from labels with corresponding callback data values.
// The prefix is prepended to each data value to form the full callback data.
func formatKeyboard(prefix string, labels, data []string) models.InlineKeyboardMarkup {
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, label := range labels {
data := prefix + label
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: data})
cb := prefix + data[i]
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: cb})
if len(row) == 2 || i == len(labels)-1 {
rows = append(rows, row)
row = nil
@@ -75,47 +60,14 @@ func formatKeyboard(prefix string, labels []string) models.InlineKeyboardMarkup
// languageKeyboard builds a keyboard from available languages.
func languageKeyboard(languages []string) models.InlineKeyboardMarkup {
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, lang := range languages {
data := "lang_" + lang
row = append(row, models.InlineKeyboardButton{Text: lang, CallbackData: data})
if len(row) == 2 || i == len(languages)-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}
}
// 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}
return formatKeyboard("lang_", languages, languages)
}
// progressKeyboard builds the progress display with a dummy button and cancel.
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
progressText := fmt.Sprintf("%.1f%%", pct)
if speed != "" {
progressText += fmt.Sprintf(" | %s/s", speed)
progressText += fmt.Sprintf(" | %s", speed)
}
if eta != "" {
progressText += fmt.Sprintf(" | ETA %s", eta)
@@ -128,85 +80,20 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku
}
}
// playlistNavKeyboard builds the paginated navigation for playlist selection.
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup {
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if totalPages < 1 {
totalPages = 1
}
// 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{
// mediaTypeKeyboard builds the initial type picker (Video/Audio/Ask).
func mediaTypeKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Video", CallbackData: "type_video"},
{Text: "Audio", CallbackData: "type_audio"},
{Text: "Ask", CallbackData: "type_ask"},
},
{
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
},
},
}
}
// deleteConfirmKeyboard builds the confirmation prompt for account deletion.
@@ -214,7 +101,7 @@ func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", userID)},
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", userID), Style: "danger"},
{Text: "Cancel", CallbackData: "back_settings"},
},
},

View File

@@ -1,197 +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,
DefaultQuality: "best",
DefaultContainer: "mp4",
}
}
format := &domain.Format{ID: prefs.DefaultQuality}
for _, f := range info.Formats {
if f.ID == prefs.DefaultQuality {
format = &f
break
}
}
job := &domain.DownloadJob{
ID: h.generateJobID(),
UserID: userID,
ChatID: chatID,
URL: entry.URL,
MediaType: prefs.DefaultMediaType,
SelectedFormat: format,
Container: prefs.DefaultContainer,
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

@@ -12,36 +12,36 @@ import (
)
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
mediaTypeLabel := "Video+Audio"
switch prefs.DefaultMediaType {
case domain.MediaTypeAudio:
mediaTypeLabel = "Audio Only"
case domain.MediaTypeVideo:
mediaTypeLabel = "Video Only"
audioFmt := prefs.DefaultAudioFormat
if audioFmt == "" {
audioFmt = "ask"
}
qualityLabel := prefs.DefaultQuality
if qualityLabel == "" {
qualityLabel = "best"
}
containerLabel := prefs.DefaultContainer
if containerLabel == "" {
containerLabel = "mp4"
videoFmt := prefs.DefaultVideoFormat
if videoFmt == "" {
videoFmt = "ask"
}
langLabel := prefs.DefaultLanguage
if langLabel == "" {
langLabel = "Default"
}
mediaTypeLabel := "Ask"
switch prefs.DefaultMediaType {
case domain.MediaTypeAudio:
mediaTypeLabel = "Audio"
case domain.MediaTypeVideo:
mediaTypeLabel = "Video"
}
rows := [][]models.InlineKeyboardButton{
{
{Text: fmt.Sprintf("Type: %s", mediaTypeLabel), CallbackData: "settings_media_type"},
{Text: fmt.Sprintf("Quality: %s", qualityLabel), CallbackData: "settings_quality"},
},
{
{Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"},
{Text: fmt.Sprintf("Media Type: %s", mediaTypeLabel), CallbackData: "settings_mediatype"},
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
},
{{Text: "Delete my data", CallbackData: "delaccount_prompt"}},
{
{Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"},
},
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
@@ -73,14 +73,17 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
}
switch setting {
case "media_type":
case "mediatype":
text, kb := h.buildMediaTypePicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "quality":
text, kb := h.buildQualityPicker(prefs)
text, kb := h.buildQualityAudioVideoPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "container":
text, kb := h.buildContainerPicker(prefs)
case "quality_audio":
text, kb := h.buildAudioQualityPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "quality_video":
text, kb := h.buildVideoQualityPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "language":
text, kb := h.buildLanguagePicker(prefs)
@@ -88,76 +91,58 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
}
}
func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
options := []struct {
label string
value domain.MediaType
data string
}{
{"Audio Only", domain.MediaTypeAudio, "settings_set_type_audio"},
{"Video Only", domain.MediaTypeVideo, "settings_set_type_video"},
{"Video+Audio", domain.MediaTypeVideoAudio, "settings_set_type_both"},
func (h *Handler) buildQualityAudioVideoPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
rows := [][]models.InlineKeyboardButton{
{{Text: "Audio", CallbackData: "settings_quality_audio"}},
{{Text: "Video", CallbackData: "settings_quality_video"}},
{{Text: "Back to Settings", CallbackData: "back_settings"}},
}
return "Choose quality type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
bitrates := []string{"ask", "best", "128k", "192k", "256k", "320k"}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range options {
label := opt.label
if opt.value == prefs.DefaultMediaType {
for _, b := range bitrates {
label := b
if b == "ask" && prefs.DefaultAudioFormat == "" {
label = "> " + label
} else if b != "ask" && b == prefs.DefaultAudioFormat {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
{Text: label, CallbackData: "settings_set_audioquality_" + b},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
{Text: "Back", CallbackData: "back_quality"},
})
return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
return "Select default audio quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
qualities := []string{"best", "2160p", "1080p", "720p", "480p", "360p"}
func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
resolutions := []string{"ask", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, q := range qualities {
label := q
if q == prefs.DefaultQuality {
for i, r := range resolutions {
label := r
if r == "ask" && prefs.DefaultVideoFormat == "" {
label = "> " + label
} else if r != "ask" && r == prefs.DefaultVideoFormat {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_quality_" + q,
Text: label, CallbackData: "settings_set_videoquality_" + r,
})
if len(row) == 3 || i == len(qualities)-1 {
if len(row) == 3 || i == len(resolutions)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
{Text: "Back", CallbackData: "back_quality"},
})
return "Select default quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
containers := []string{"mp4", "mkv", "webm", "mp3", "m4a", "opus"}
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, c := range containers {
label := c
if c == prefs.DefaultContainer {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_container_" + c,
})
if len(row) == 3 || i == len(containers)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select default container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
return "Select default video quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
@@ -188,6 +173,31 @@ func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, mo
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) {
parts := strings.SplitN(setting, "_", 2)
if len(parts) < 2 {
@@ -203,21 +213,27 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
changed := false
switch action {
case "type":
case "mediatype":
switch value {
case "ask":
prefs.DefaultMediaType = domain.MediaTypeAsk
case "audio":
prefs.DefaultMediaType = domain.MediaTypeAudio
case "video":
prefs.DefaultMediaType = domain.MediaTypeVideo
case "both":
prefs.DefaultMediaType = domain.MediaTypeVideoAudio
}
changed = true
case "quality":
prefs.DefaultQuality = value
case "audioquality":
if value == "ask" {
value = ""
}
prefs.DefaultAudioFormat = value
changed = true
case "container":
prefs.DefaultContainer = value
case "videoquality":
if value == "ask" {
value = ""
}
prefs.DefaultVideoFormat = value
changed = true
case "language":
prefs.DefaultLanguage = value
@@ -232,7 +248,7 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
// deleteAccountPrompt shows the deletion confirmation prompt.
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, userID int64) {
kb := deleteConfirmKeyboard(userID)
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (preferences, quota, history).\nAre you sure?", &kb)
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (preferences, quotas, history).\nAre you sure?", &kb)
}
// deleteAccountConfirm permanently deletes all user data.

View File

@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE user_preferences ADD COLUMN default_audio_format TEXT NOT NULL DEFAULT 'best';
ALTER TABLE user_preferences ADD COLUMN default_video_format TEXT NOT NULL DEFAULT 'best';
-- +goose Down
ALTER TABLE user_preferences DROP COLUMN default_audio_format;
ALTER TABLE user_preferences DROP COLUMN default_video_format;

View File

@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE user_preferences ADD COLUMN default_audio_container TEXT NOT NULL DEFAULT 'mp3';
ALTER TABLE user_preferences ADD COLUMN default_video_container TEXT NOT NULL DEFAULT 'mp4';
-- +goose Down
ALTER TABLE user_preferences DROP COLUMN default_audio_container;
ALTER TABLE user_preferences DROP COLUMN default_video_container;

View File

@@ -65,7 +65,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (int64, error) {
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
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,
); err != nil {
return nil, err
@@ -75,12 +75,14 @@ func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, e
func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
row := s.db.QueryRow(`
SELECT default_media_type, default_quality, default_container, default_language
SELECT default_media_type, default_audio_format, default_video_format,
default_audio_container, default_video_container, default_language
FROM user_preferences WHERE user_id = ?
`, userID)
var p domain.UserPreferences
var mediaType string
if err := row.Scan(&mediaType, &p.DefaultQuality, &p.DefaultContainer, &p.DefaultLanguage); err != nil {
if err := row.Scan(&mediaType, &p.DefaultAudioFormat, &p.DefaultVideoFormat,
&p.DefaultAudioContainer, &p.DefaultVideoContainer, &p.DefaultLanguage); err != nil {
return nil, err
}
switch mediaType {
@@ -88,14 +90,18 @@ func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
p.DefaultMediaType = domain.MediaTypeAudio
case "video":
p.DefaultMediaType = domain.MediaTypeVideo
case "ask":
p.DefaultMediaType = domain.MediaTypeAsk
default:
p.DefaultMediaType = domain.MediaTypeVideoAudio
p.DefaultMediaType = domain.MediaTypeAsk
}
return &p, nil
}
func mediaTypeToString(mt domain.MediaType) string {
switch mt {
case domain.MediaTypeAsk:
return "ask"
case domain.MediaTypeAudio:
return "audio"
case domain.MediaTypeVideo:
@@ -107,11 +113,13 @@ func mediaTypeToString(mt domain.MediaType) string {
func (s *Store) UpdatePreferences(userID int64, prefs *domain.UserPreferences) error {
_, err := s.db.Exec(`
UPDATE user_preferences SET default_media_type=?, default_quality=?,
default_container=?, default_language=?
UPDATE user_preferences SET default_media_type=?, default_audio_format=?,
default_video_format=?, default_audio_container=?,
default_video_container=?, default_language=?
WHERE user_id=?
`, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultQuality,
prefs.DefaultContainer, prefs.DefaultLanguage, userID)
`, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultAudioFormat,
prefs.DefaultVideoFormat, prefs.DefaultAudioContainer,
prefs.DefaultVideoContainer, prefs.DefaultLanguage, userID)
return err
}

View File

@@ -59,13 +59,15 @@ func TestPreferences(t *testing.T) {
if err != nil {
t.Fatalf("GetOrCreatePreferences() failed: %v", err)
}
if prefs.DefaultMediaType != domain.MediaTypeVideoAudio {
t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType)
if prefs.DefaultMediaType != domain.MediaTypeAsk {
t.Errorf("default media type = %v, want Ask", prefs.DefaultMediaType)
}
prefs.DefaultMediaType = domain.MediaTypeAudio
prefs.DefaultQuality = "best"
prefs.DefaultContainer = "mp3"
prefs.DefaultAudioFormat = "best"
prefs.DefaultVideoFormat = "1080p"
prefs.DefaultAudioContainer = "mp3"
prefs.DefaultVideoContainer = "mp4"
prefs.DefaultLanguage = "en"
if err := s.UpdatePreferences(userID, prefs); err != nil {
t.Fatalf("UpdatePreferences() failed: %v", err)
@@ -78,11 +80,17 @@ func TestPreferences(t *testing.T) {
if got.DefaultMediaType != domain.MediaTypeAudio {
t.Errorf("media type = %v, want Audio", got.DefaultMediaType)
}
if got.DefaultQuality != "best" {
t.Errorf("quality = %q, want best", got.DefaultQuality)
if got.DefaultAudioFormat != "best" {
t.Errorf("audio format = %q, want best", got.DefaultAudioFormat)
}
if got.DefaultContainer != "mp3" {
t.Errorf("container = %q, want mp3", got.DefaultContainer)
if got.DefaultVideoFormat != "1080p" {
t.Errorf("video format = %q, want 1080p", got.DefaultVideoFormat)
}
if got.DefaultAudioContainer != "mp3" {
t.Errorf("audio container = %q, want mp3", got.DefaultAudioContainer)
}
if got.DefaultVideoContainer != "mp4" {
t.Errorf("video container = %q, want mp4", got.DefaultVideoContainer)
}
if got.DefaultLanguage != "en" {
t.Errorf("language = %q, want en", got.DefaultLanguage)