Compare commits
17 Commits
dfe946a41b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
3099244614
|
|||
|
d0be877c0e
|
|||
|
315107839c
|
|||
|
4af8a95af6
|
|||
|
b11b5649a5
|
|||
|
6bbc87a7c8
|
|||
|
d0291d1e9e
|
|||
|
3f56ffbba9
|
|||
|
f5204b9554
|
|||
|
464f20a46c
|
|||
|
609237f4e3
|
|||
|
57dffc4194
|
|||
|
58baa1b769
|
|||
|
df08a8840f
|
|||
|
959b7e388d
|
|||
|
7c02cd3d89
|
|||
|
37e7f918d0
|
19
.gitignore
vendored
19
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -14,7 +14,18 @@ RUN go build -o /usr/local/bin/uptodown-bot ./cmd/bot/
|
|||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates tzdata
|
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates tzdata py3-pip curl
|
||||||
|
|
||||||
|
# Install yt-dlp plugins (add more as needed).
|
||||||
|
# PIP_BREAK_SYSTEM_PACKAGES bypasses PEP 668 — safe in Docker.
|
||||||
|
RUN PIP_BREAK_SYSTEM_PACKAGES=1 pip3 install --root-user-action=ignore --no-cache-dir hanime-plugin
|
||||||
|
|
||||||
|
# Install Deno JS runtime (required by some yt-dlp plugins)
|
||||||
|
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||||
|
ENV PATH="/root/.deno/bin:${PATH}"
|
||||||
|
|
||||||
|
# Verify Deno is available
|
||||||
|
RUN deno --version
|
||||||
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -5,20 +5,24 @@ Telegram bot for downloading media from any site yt-dlp supports.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Download audio, video, or combined audio+video from YouTube, Spotify, SoundCloud, Vimeo, and hundreds more
|
- Download audio, video, or combined audio+video from YouTube, Spotify, SoundCloud, Vimeo, and hundreds more
|
||||||
|
- Search support: search YouTube/YouTube Music by name when no URL is provided
|
||||||
|
- DRM fallback: automatically searches YouTube when Spotify/DRM-protected content is detected
|
||||||
- Thumbnail preview with metadata (title, uploader, duration) before download
|
- Thumbnail preview with metadata (title, uploader, duration) before download
|
||||||
- Audio-only sites (Spotify, SoundCloud, YouTube Music, etc.) auto-detect and skip to audio quality selection
|
- Audio-only sites (Spotify, SoundCloud, YouTube Music, etc.) auto-detect and skip to audio quality selection
|
||||||
- Multi-step format selection: media type, quality, language, container
|
- Multi-step format selection: type picker (Video/Audio/Ask), quality, secondary format (video+audio combine), language
|
||||||
- Playlist support with paginated entry selection (10 per page, select all, confirm with count)
|
- Auto-select: skip all pickers when both media type AND quality are set in preferences (quality "Ask" shows a filtered picker)
|
||||||
- Download progress updates (every 5% with speed and ETA)
|
- Audio remuxed to m4a (Telegram-friendly), video remuxed to mp4
|
||||||
|
- Download progress updates (with speed and ETA)
|
||||||
- Cancel downloads at any time (terminates yt-dlp process)
|
- Cancel downloads at any time (terminates yt-dlp process)
|
||||||
- Per-user quota system (daily, weekly, monthly)
|
- Per-user quota system (daily, weekly, monthly; 0 = unlimited)
|
||||||
- User preferences (default quality, container, language)
|
- User preferences (default media type, audio/video quality with "Ask" option, language)
|
||||||
- Download history
|
- Download history with pagination
|
||||||
- Configurable file size limit
|
- Configurable file size limit (default 50 MB)
|
||||||
- Cookies support for age-restricted content
|
- Cookies support for age-restricted and authenticated content
|
||||||
- Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
|
- Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
|
||||||
- Webhook and polling modes
|
- Webhook and polling modes
|
||||||
- SQLite database for persistence
|
- SQLite database for persistence
|
||||||
|
- yt-dlp plugin support — `hanime-plugin` bundled in Docker image
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -50,6 +54,17 @@ Copy `.env.example` to `.env` and configure:
|
|||||||
| `BOT_TLS_CERT` | empty | TLS certificate path |
|
| `BOT_TLS_CERT` | empty | TLS certificate path |
|
||||||
| `BOT_TLS_KEY` | empty | TLS key path |
|
| `BOT_TLS_KEY` | empty | TLS key path |
|
||||||
|
|
||||||
|
### Cookies (for age-restricted and authenticated content)
|
||||||
|
|
||||||
|
Some sites (YouTube, Spotify, etc.) require cookies for age-restricted content. To set up:
|
||||||
|
|
||||||
|
1. Install a browser extension that exports cookies in Netscape format (e.g., "Get cookies.txt" for Chrome/Firefox).
|
||||||
|
2. Log into the site with a throwaway account.
|
||||||
|
3. Export cookies to a file named `cookies.txt` in the project root.
|
||||||
|
4. Set `COOKIES_FILE=/cookies.txt` in your `.env` file.
|
||||||
|
|
||||||
|
For Docker, the cookie file is automatically mounted from `./cookies.txt` (see `docker-compose.yml`).
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
### Polling mode (default)
|
### Polling mode (default)
|
||||||
@@ -73,6 +88,46 @@ make run-dev
|
|||||||
make docker-run
|
make docker-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Docker image bundles the `hanime-plugin` and the Deno JS runtime for sites like
|
||||||
|
hanime.tv, hstream.moe, and hentaihaven.com. See [yt-dlp plugins](#yt-dlp-plugins) for details.
|
||||||
|
|
||||||
|
### Local (non-Docker)
|
||||||
|
|
||||||
|
If running outside Docker, the bot uses whatever yt-dlp is in your PATH.
|
||||||
|
Install plugins and a JS runtime as described below under [yt-dlp plugins](#yt-dlp-plugins).
|
||||||
|
|
||||||
|
## yt-dlp plugins
|
||||||
|
|
||||||
|
Some sites require yt-dlp plugins or a JavaScript runtime to work. The Docker image
|
||||||
|
bundles these automatically; for local installs you need to set them up manually.
|
||||||
|
|
||||||
|
### hanime-plugin
|
||||||
|
|
||||||
|
Adds support for hanime.tv, hstream.moe, hentaihaven.com and others.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install hanime-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin requires a JavaScript runtime. [Deno](https://deno.com) is recommended:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
curl -fsSL https://deno.land/install.sh | sh
|
||||||
|
|
||||||
|
# Add to your shell config:
|
||||||
|
export PATH="$HOME/.deno/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding other plugins
|
||||||
|
|
||||||
|
yt-dlp plugins are Python packages installed via pip. Most work without
|
||||||
|
additional runtimes. Install any pip-installable yt-dlp plugin with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install <plugin-name>
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
tgbot "github.com/go-telegram/bot"
|
tgbot "github.com/go-telegram/bot"
|
||||||
)
|
)
|
||||||
@@ -23,10 +24,26 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) {
|
|||||||
tlsCert := os.Getenv("BOT_TLS_CERT")
|
tlsCert := os.Getenv("BOT_TLS_CERT")
|
||||||
tlsKey := os.Getenv("BOT_TLS_KEY")
|
tlsKey := os.Getenv("BOT_TLS_KEY")
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: listenAddr,
|
||||||
|
Handler: b.WebhookHandler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown HTTP server gracefully when context is cancelled
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
slog.Info("shutting down webhook server")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
slog.Error("webhook server shutdown", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if tlsCert != "" && tlsKey != "" {
|
if tlsCert != "" && tlsKey != "" {
|
||||||
slog.Info("starting HTTPS webhook", "addr", listenAddr)
|
slog.Info("starting HTTPS webhook", "addr", listenAddr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, b.WebhookHandler()); err != nil {
|
if err := server.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Error("HTTPS server", "error", err)
|
slog.Error("HTTPS server", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -34,7 +51,7 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) {
|
|||||||
} else {
|
} else {
|
||||||
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
|
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := http.ListenAndServe(listenAddr, b.WebhookHandler()); err != nil {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Error("HTTP server", "error", err)
|
slog.Error("HTTP server", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ services:
|
|||||||
- HTTP_PROXY=${HTTP_PROXY}
|
- HTTP_PROXY=${HTTP_PROXY}
|
||||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||||
- DB_PATH=${DB_PATH}
|
- DB_PATH=${DB_PATH}
|
||||||
|
- COOKIES_FILE=${COOKIES_FILE}
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
volumes:
|
volumes:
|
||||||
- uptodown_data:/data
|
- uptodown_data:/data
|
||||||
|
- ./cookies.txt:/cookies.txt # Optional: cookies file for yt-dlp auth
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
uptodown_data:
|
uptodown_data:
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"uptodownBot/internal/domain"
|
"uptodownBot/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var progressRE = regexp.MustCompile(`\[\w+\]\s+([\d.]+)%\s+of\s+~?([\d.]+)(\w+)`)
|
var progressRE = regexp.MustCompile(`\[\w+\]\s+([\d.]+)%\s+of\s+~?\s*([\d.]+)(\w+)`)
|
||||||
var progressTemplateRE = regexp.MustCompile(`^DLPROG\|([\d.]+)\|(.*?)\|(.*)$`)
|
var progressTemplateRE = regexp.MustCompile(`^DLPROG\|\s*([\d.]+)%\|\s*(.*?)\s*\|(.*)$`)
|
||||||
|
|
||||||
type progressUpdate struct {
|
type progressUpdate struct {
|
||||||
pct float64
|
pct float64
|
||||||
@@ -71,36 +71,43 @@ func parseProgressLine(line string) *progressUpdate {
|
|||||||
rest = rest[idx+1:]
|
rest = rest[idx+1:]
|
||||||
}
|
}
|
||||||
if atIdx := strings.Index(rest, " at "); atIdx >= 0 {
|
if atIdx := strings.Index(rest, " at "); atIdx >= 0 {
|
||||||
afterAt := rest[atIdx+4:]
|
afterAt := strings.TrimSpace(rest[atIdx+4:])
|
||||||
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 {
|
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 {
|
||||||
u.speed = strings.TrimSpace(afterAt[:spaceIdx])
|
speedVal := afterAt[:spaceIdx]
|
||||||
|
if !strings.EqualFold(speedVal, "unknown") && speedVal != "N/A" {
|
||||||
|
u.speed = speedVal
|
||||||
|
}
|
||||||
rest = afterAt[spaceIdx:]
|
rest = afterAt[spaceIdx:]
|
||||||
|
} else {
|
||||||
|
if !strings.EqualFold(afterAt, "unknown") && afterAt != "N/A" {
|
||||||
|
u.speed = afterAt
|
||||||
|
}
|
||||||
|
rest = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 {
|
if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 {
|
||||||
etaStr := strings.TrimSpace(rest[etaIdx+4:])
|
etaStr := strings.TrimSpace(rest[etaIdx+4:])
|
||||||
|
if parenPos := strings.Index(etaStr, "("); parenPos > 0 {
|
||||||
|
etaStr = strings.TrimSpace(etaStr[:parenPos])
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(etaStr, "unknown") && etaStr != "N/A" {
|
||||||
u.eta = etaStr
|
u.eta = etaStr
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
// readProgress reads yt-dlp stderr and sends progress updates to the channel.
|
// readProgress reads yt-dlp output and sends progress updates to the channel.
|
||||||
// The caller is responsible for closing the updates channel.
|
// The caller is responsible for closing the updates channel.
|
||||||
func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
|
func readProgress(reader io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
|
||||||
var stderrBuf strings.Builder
|
scanner := bufio.NewScanner(reader)
|
||||||
scanner := bufio.NewScanner(stderr)
|
|
||||||
lineCount := 0
|
lineCount := 0
|
||||||
matchCount := 0
|
matchCount := 0
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
lineCount++
|
lineCount++
|
||||||
|
|
||||||
if stderrBuf.Len() < 4096 {
|
|
||||||
stderrBuf.WriteString(line)
|
|
||||||
stderrBuf.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed := parseProgressLine(line)
|
parsed := parseProgressLine(line)
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
continue
|
continue
|
||||||
@@ -111,6 +118,5 @@ func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job
|
|||||||
job.ETA = parsed.eta
|
job.ETA = parsed.eta
|
||||||
updates <- job
|
updates <- job
|
||||||
}
|
}
|
||||||
job.StderrLog = stderrBuf.String()
|
slog.Info("readProgress finished", "url", job.URL, "lines", lineCount, "matches", matchCount)
|
||||||
slog.Info("readProgress finished", "url", job.URL, "lines", lineCount, "matches", matchCount, "stderr_len", len(job.StderrLog))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ func TestParseProgressLine(t *testing.T) {
|
|||||||
eta: "00:02",
|
eta: "00:02",
|
||||||
hasRes: true,
|
hasRes: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 100.0% of ~ 1.00KiB at 976.20B/s ETA Unknown (frag 0/18)",
|
||||||
|
pct: 100.0,
|
||||||
|
speed: "976.20B/s",
|
||||||
|
eta: "",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 16.7% of ~ 18.00KiB at 976.20B/s ETA Unknown (frag 0/18)",
|
||||||
|
pct: 16.7,
|
||||||
|
speed: "976.20B/s",
|
||||||
|
eta: "",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 11.1% of ~ 8.34MiB at 716.47KiB/s ETA 00:08 (frag 1/18)",
|
||||||
|
pct: 11.1,
|
||||||
|
speed: "716.47KiB/s",
|
||||||
|
eta: "00:08",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
line: "[download] 1.2% of unknown size at 0.00B/s ETA Unknown",
|
line: "[download] 1.2% of unknown size at 0.00B/s ETA Unknown",
|
||||||
hasRes: false,
|
hasRes: false,
|
||||||
@@ -109,21 +130,21 @@ func TestParseProgressTemplate(t *testing.T) {
|
|||||||
hasRes bool
|
hasRes bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
line: "DLPROG|45.2|2.34MiB/s|00:45",
|
line: "DLPROG| 45.2%| 2.34MiB/s|00:45",
|
||||||
pct: 45.2,
|
pct: 45.2,
|
||||||
speed: "2.34MiB/s",
|
speed: "2.34MiB/s",
|
||||||
eta: "00:45",
|
eta: "00:45",
|
||||||
hasRes: true,
|
hasRes: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
line: "DLPROG|100.0|5.00MiB/s|00:00",
|
line: "DLPROG|100.0%| 5.00MiB/s|00:00",
|
||||||
pct: 100.0,
|
pct: 100.0,
|
||||||
speed: "5.00MiB/s",
|
speed: "5.00MiB/s",
|
||||||
eta: "00:00",
|
eta: "00:00",
|
||||||
hasRes: true,
|
hasRes: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
line: "DLPROG|0.0||",
|
line: "DLPROG| 0.0%||",
|
||||||
pct: 0.0,
|
pct: 0.0,
|
||||||
speed: "",
|
speed: "",
|
||||||
eta: "",
|
eta: "",
|
||||||
@@ -142,12 +163,26 @@ func TestParseProgressTemplate(t *testing.T) {
|
|||||||
hasRes: false,
|
hasRes: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
line: "DLPROG|50.0|Unknown|Unknown",
|
line: "DLPROG| 50.0%| Unknown|Unknown",
|
||||||
pct: 50.0,
|
pct: 50.0,
|
||||||
speed: "",
|
speed: "",
|
||||||
eta: "",
|
eta: "",
|
||||||
hasRes: true,
|
hasRes: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
line: "DLPROG| 16.7%| 1.05KiB/s|Unknown",
|
||||||
|
pct: 16.7,
|
||||||
|
speed: "1.05KiB/s",
|
||||||
|
eta: "",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "DLPROG| 11.1%| 754.59KiB/s|00:08",
|
||||||
|
pct: 11.1,
|
||||||
|
speed: "754.59KiB/s",
|
||||||
|
eta: "00:08",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := parseProgressTemplate(tt.line)
|
got := parseProgressTemplate(tt.line)
|
||||||
@@ -182,6 +217,9 @@ func TestParseProgressLineEdgeCases(t *testing.T) {
|
|||||||
{"[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", "multiple spaces", true},
|
{"[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", "multiple spaces", true},
|
||||||
{"[download] 100.0% of 1.00GiB at 999.99MiB/s ETA 00:00", "high speed", true},
|
{"[download] 100.0% of 1.00GiB at 999.99MiB/s ETA 00:00", "high speed", true},
|
||||||
{"[download] 100.0% of 999.99TiB at 1.00TiB/s ETA 00:00", "large numbers", true},
|
{"[download] 100.0% of 999.99TiB at 1.00TiB/s ETA 00:00", "large numbers", true},
|
||||||
|
{"[download] 100.0% of ~ 1.00KiB at 976.20B/s ETA Unknown (frag 0/18)", "hls frag first", true},
|
||||||
|
{"[download] 16.7% of ~ 18.00KiB at 976.20B/s ETA Unknown (frag 0/18)", "hls frag", true},
|
||||||
|
{"[download] 11.1% of ~ 8.34MiB at 716.47KiB/s ETA 00:08 (frag 1/18)", "hls frag with eta", true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -21,6 +23,7 @@ import (
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
binaryPath string
|
binaryPath string
|
||||||
cookiesFile string
|
cookiesFile string
|
||||||
|
proxyURL string
|
||||||
cache map[string]*cachedMediaInfo
|
cache map[string]*cachedMediaInfo
|
||||||
cacheMu sync.Mutex
|
cacheMu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -60,9 +63,19 @@ func NewClient(cookiesFile string) (*Client, error) {
|
|||||||
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
|
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
|
||||||
}
|
}
|
||||||
slog.Info("yt-dlp found", "path", path)
|
slog.Info("yt-dlp found", "path", path)
|
||||||
|
|
||||||
|
proxyURL := os.Getenv("HTTP_PROXY")
|
||||||
|
if proxyURL == "" {
|
||||||
|
proxyURL = os.Getenv("HTTPS_PROXY")
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
slog.Info("using proxy for yt-dlp", "proxy", proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
binaryPath: path,
|
binaryPath: path,
|
||||||
cookiesFile: cookiesFile,
|
cookiesFile: cookiesFile,
|
||||||
|
proxyURL: proxyURL,
|
||||||
cache: make(map[string]*cachedMediaInfo),
|
cache: make(map[string]*cachedMediaInfo),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -118,6 +131,9 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
if c.cookiesFile != "" {
|
if c.cookiesFile != "" {
|
||||||
args = append(args, "--cookies", c.cookiesFile)
|
args = append(args, "--cookies", c.cookiesFile)
|
||||||
}
|
}
|
||||||
|
if c.proxyURL != "" {
|
||||||
|
args = append(args, "--proxy", c.proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("fetching media info", "url", url)
|
slog.Info("fetching media info", "url", url)
|
||||||
out, err := c.run(args)
|
out, err := c.run(args)
|
||||||
@@ -140,14 +156,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
Uploader: info.Uploader,
|
Uploader: info.Uploader,
|
||||||
Thumbnail: info.Thumbnail,
|
Thumbnail: info.Thumbnail,
|
||||||
Duration: info.Duration,
|
Duration: info.Duration,
|
||||||
IsPlaylist: info.Playlist != "",
|
|
||||||
}
|
|
||||||
|
|
||||||
if mi.IsPlaylist {
|
|
||||||
mi.PlaylistCount = info.PlaylistCount
|
|
||||||
mi.PlaylistEntries = c.parsePlaylistEntries(info.Entries)
|
|
||||||
c.setCache(url, mi)
|
|
||||||
return mi, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
langSet := make(map[string]bool)
|
langSet := make(map[string]bool)
|
||||||
@@ -155,7 +163,7 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
for _, f := range info.Formats {
|
for _, f := range info.Formats {
|
||||||
ff := c.convertFormat(f)
|
ff := c.convertFormat(f)
|
||||||
mi.Formats = append(mi.Formats, ff)
|
mi.Formats = append(mi.Formats, ff)
|
||||||
if ff.Language != "" && !langSet[ff.Language] {
|
if (ff.HasAudio || ff.HasVideo) && ff.Language != "" && !langSet[ff.Language] {
|
||||||
langSet[ff.Language] = true
|
langSet[ff.Language] = true
|
||||||
mi.Languages = append(mi.Languages, ff.Language)
|
mi.Languages = append(mi.Languages, ff.Language)
|
||||||
}
|
}
|
||||||
@@ -169,47 +177,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
return mi, nil
|
return mi, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlaylistInfo fetches a flat playlist listing (fast, no format detail).
|
|
||||||
func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
|
|
||||||
if cached := c.getCached(url); cached != nil {
|
|
||||||
slog.Debug("cache hit", "url", url)
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
"-J", "--flat-playlist", "--no-download",
|
|
||||||
"--no-warnings",
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
if c.cookiesFile != "" {
|
|
||||||
args = append(args, "--cookies", c.cookiesFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("fetching playlist info", "url", url)
|
|
||||||
out, err := c.run(args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("yt-dlp playlist info failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var info ytdlpInfo
|
|
||||||
if err := json.Unmarshal(out, &info); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse yt-dlp playlist output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mi := &domain.MediaInfo{
|
|
||||||
URL: url,
|
|
||||||
Title: info.Title,
|
|
||||||
Uploader: info.Uploader,
|
|
||||||
Thumbnail: info.Thumbnail,
|
|
||||||
IsPlaylist: true,
|
|
||||||
PlaylistCount: info.PlaylistCount,
|
|
||||||
PlaylistEntries: c.parsePlaylistEntries(info.Entries),
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setCache(url, mi)
|
|
||||||
return mi, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry {
|
func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry {
|
||||||
var result []domain.PlaylistEntry
|
var result []domain.PlaylistEntry
|
||||||
for _, raw := range entries {
|
for _, raw := range entries {
|
||||||
@@ -265,28 +232,17 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
formatID := job.SelectedFormat.ID
|
formatID := job.SelectedFormat.ID
|
||||||
var args []string
|
var args []string
|
||||||
|
|
||||||
// Use explicit format string if set (video+audio combination)
|
// Use format string for the download — yt-dlp handles container choice
|
||||||
if job.FormatString != "" {
|
if job.FormatString != "" {
|
||||||
args = append(args, "-f", job.FormatString)
|
args = append(args, "-f", job.FormatString)
|
||||||
if job.Container != "" {
|
|
||||||
args = append(args, "--merge-output-format", job.Container)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Build format string for merge
|
|
||||||
switch job.MediaType {
|
switch job.MediaType {
|
||||||
case domain.MediaTypeAudio:
|
case domain.MediaTypeAudio:
|
||||||
args = append(args, "-f", formatID, "-x")
|
args = append(args, "-f", formatID, "-x")
|
||||||
if job.Container != "" {
|
|
||||||
args = append(args, "--audio-format", job.Container)
|
|
||||||
}
|
|
||||||
case domain.MediaTypeVideo:
|
case domain.MediaTypeVideo:
|
||||||
args = append(args, "-f", formatID)
|
args = append(args, "-f", formatID)
|
||||||
case domain.MediaTypeVideoAudio:
|
case domain.MediaTypeVideoAudio:
|
||||||
// Download best video + best audio that match
|
|
||||||
args = append(args, "-f", formatID+"+bestaudio/best")
|
args = append(args, "-f", formatID+"+bestaudio/best")
|
||||||
if job.Container != "" {
|
|
||||||
args = append(args, "--merge-output-format", job.Container)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,20 +250,37 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
|
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
|
||||||
args = append(args, "--output", outputPath)
|
args = append(args, "--output", outputPath)
|
||||||
|
|
||||||
// Progress reporting using progress-template for machine-readable output
|
// Remux to Telegram-friendly containers: mp4 for video, m4a for audio-only.
|
||||||
args = append(args, "--newline")
|
if job.MediaType == domain.MediaTypeAudio {
|
||||||
args = append(args, "--progress-template", "stderr:DLPROG|%(progress.percent)s|%(progress._speed_str)s|%(progress._eta_str)s")
|
args = append(args, "--merge-output-format", "m4a")
|
||||||
|
} else {
|
||||||
|
args = append(args, "--merge-output-format", "mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --progress forces progress output in non-TTY (pipe) environments
|
||||||
|
// --newline uses \n instead of \r so bufio.Scanner can read line-by-line
|
||||||
|
args = append(args, "--progress", "--newline")
|
||||||
|
|
||||||
|
// Allow resuming partial downloads
|
||||||
|
args = append(args, "--continue")
|
||||||
|
|
||||||
|
// Enforce max file size limit
|
||||||
|
if job.MaxFileSize > 0 {
|
||||||
|
args = append(args, "--max-filesize", strconv.FormatInt(job.MaxFileSize, 10))
|
||||||
|
}
|
||||||
|
|
||||||
// Language selection if specified
|
// Language selection if specified
|
||||||
if job.Language != "" {
|
if job.Language != "" {
|
||||||
args = append(args, "--sub-langs", job.Language)
|
args = append(args, "--sub-langs", job.Language)
|
||||||
args = append(args, "--audio-multi-streams")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cookies
|
// Cookies
|
||||||
if c.cookiesFile != "" {
|
if c.cookiesFile != "" {
|
||||||
args = append(args, "--cookies", c.cookiesFile)
|
args = append(args, "--cookies", c.cookiesFile)
|
||||||
}
|
}
|
||||||
|
if c.proxyURL != "" {
|
||||||
|
args = append(args, "--proxy", c.proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
args = append(args, job.URL)
|
args = append(args, job.URL)
|
||||||
|
|
||||||
@@ -316,6 +289,13 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
cmd := exec.Command(c.binaryPath, args...)
|
cmd := exec.Command(c.binaryPath, args...)
|
||||||
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
|
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
|
||||||
|
|
||||||
|
// yt-dlp outputs [download] progress lines to stdout when saving to a file
|
||||||
|
// and to stderr when using -o -. Since we save to a file, read stdout for progress.
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
stderr, err := cmd.StderrPipe()
|
stderr, err := cmd.StderrPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create stderr pipe: %w", err)
|
return nil, fmt.Errorf("create stderr pipe: %w", err)
|
||||||
@@ -340,10 +320,16 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
|
|
||||||
progressDone := make(chan struct{})
|
progressDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
readProgress(stderr, updates, job)
|
readProgress(stdout, updates, job)
|
||||||
close(progressDone)
|
close(progressDone)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Capture stderr (warnings, errors) for diagnostics
|
||||||
|
go func() {
|
||||||
|
slurp, _ := io.ReadAll(stderr)
|
||||||
|
job.StderrLog = string(slurp)
|
||||||
|
}()
|
||||||
|
|
||||||
// Wait for either completion or cancellation
|
// Wait for either completion or cancellation
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -406,6 +392,9 @@ func (c *Client) SearchYoutube(query string) (url, title string, err error) {
|
|||||||
if c.cookiesFile != "" {
|
if c.cookiesFile != "" {
|
||||||
args = append(args, "--cookies", c.cookiesFile)
|
args = append(args, "--cookies", c.cookiesFile)
|
||||||
}
|
}
|
||||||
|
if c.proxyURL != "" {
|
||||||
|
args = append(args, "--proxy", c.proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
out, err := c.run(args)
|
out, err := c.run(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -439,6 +428,9 @@ func (c *Client) Search(query, source string, limit int) ([]domain.PlaylistEntry
|
|||||||
if c.cookiesFile != "" {
|
if c.cookiesFile != "" {
|
||||||
args = append(args, "--cookies", c.cookiesFile)
|
args = append(args, "--cookies", c.cookiesFile)
|
||||||
}
|
}
|
||||||
|
if c.proxyURL != "" {
|
||||||
|
args = append(args, "--proxy", c.proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("searching", "query", query, "source", source)
|
slog.Info("searching", "query", query, "source", source)
|
||||||
out, err := c.run(args)
|
out, err := c.run(args)
|
||||||
@@ -527,17 +519,3 @@ func formatLabel(f domain.Format) string {
|
|||||||
}
|
}
|
||||||
return f.ID
|
return f.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainerOptions returns the container format options for a given media type.
|
|
||||||
func ContainerOptions(mt domain.MediaType) []string {
|
|
||||||
switch mt {
|
|
||||||
case domain.MediaTypeAudio:
|
|
||||||
return []string{"mp3", "m4a", "opus"}
|
|
||||||
case domain.MediaTypeVideo:
|
|
||||||
return []string{"mp4", "mkv", "webm"}
|
|
||||||
case domain.MediaTypeVideoAudio:
|
|
||||||
return []string{"mp4", "mkv"}
|
|
||||||
default:
|
|
||||||
return []string{"mp4"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,37 +52,6 @@ func TestDeduplicateResolutions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContainerOptions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
mt domain.MediaType
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{domain.MediaTypeAudio, []string{"mp3", "m4a", "opus"}},
|
|
||||||
{domain.MediaTypeVideo, []string{"mp4", "mkv", "webm"}},
|
|
||||||
{domain.MediaTypeVideoAudio, []string{"mp4", "mkv"}},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := ContainerOptions(tt.mt)
|
|
||||||
if len(got) != len(tt.want) {
|
|
||||||
t.Errorf("ContainerOptions(%v) = %v, want %v", tt.mt, got, tt.want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i, c := range got {
|
|
||||||
if c != tt.want[i] {
|
|
||||||
t.Errorf("ContainerOptions(%v)[%d] = %s, want %s", tt.mt, i, c, tt.want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainerOptionsZeroValue(t *testing.T) {
|
|
||||||
got := ContainerOptions(domain.MediaType(0))
|
|
||||||
want := []string{"mp4"}
|
|
||||||
if len(got) != 1 || got[0] != want[0] {
|
|
||||||
t.Errorf("ContainerOptions(0) = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatLabel(t *testing.T) {
|
func TestFormatLabel(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
f domain.Format
|
f domain.Format
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import "os/exec"
|
|||||||
type MediaType int
|
type MediaType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MediaTypeAudio MediaType = iota + 1
|
MediaTypeAsk MediaType = iota
|
||||||
|
MediaTypeAudio
|
||||||
MediaTypeVideo
|
MediaTypeVideo
|
||||||
MediaTypeVideoAudio
|
MediaTypeVideoAudio
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m MediaType) String() string {
|
func (m MediaType) String() string {
|
||||||
switch m {
|
switch m {
|
||||||
|
case MediaTypeAsk:
|
||||||
|
return "ask"
|
||||||
case MediaTypeAudio:
|
case MediaTypeAudio:
|
||||||
return "audio"
|
return "audio"
|
||||||
case MediaTypeVideo:
|
case MediaTypeVideo:
|
||||||
@@ -50,9 +53,6 @@ type MediaInfo struct {
|
|||||||
Duration float64
|
Duration float64
|
||||||
Formats []Format
|
Formats []Format
|
||||||
Languages []string
|
Languages []string
|
||||||
IsPlaylist bool
|
|
||||||
PlaylistCount int
|
|
||||||
PlaylistEntries []PlaylistEntry
|
|
||||||
EstimatedSize int64
|
EstimatedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +118,16 @@ type DownloadJob struct {
|
|||||||
MediaType MediaType
|
MediaType MediaType
|
||||||
SelectedFormat *Format
|
SelectedFormat *Format
|
||||||
FormatString string
|
FormatString string
|
||||||
Container string
|
|
||||||
Language string
|
Language string
|
||||||
Status DownloadStatus
|
Status DownloadStatus
|
||||||
Progress float64
|
Progress float64
|
||||||
Speed string
|
Speed string
|
||||||
ETA string
|
ETA string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
MaxFileSize int64
|
||||||
FilePath string
|
FilePath string
|
||||||
Title string
|
Title string
|
||||||
|
ProgressPrefix string
|
||||||
QuotaApplied bool
|
QuotaApplied bool
|
||||||
StderrLog string
|
StderrLog string
|
||||||
Done chan struct{}
|
Done chan struct{}
|
||||||
@@ -171,14 +172,6 @@ type DownloadRecord struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaylistState tracks the user's current playlist selection flow.
|
|
||||||
type PlaylistState struct {
|
|
||||||
Entries []PlaylistEntry
|
|
||||||
Page int
|
|
||||||
PerPage int
|
|
||||||
Selected map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchState tracks the user's current search results flow.
|
// SearchState tracks the user's current search results flow.
|
||||||
type SearchState struct {
|
type SearchState struct {
|
||||||
Query string
|
Query string
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func TestMediaTypeString(t *testing.T) {
|
|||||||
{MediaTypeAudio, "audio"},
|
{MediaTypeAudio, "audio"},
|
||||||
{MediaTypeVideo, "video"},
|
{MediaTypeVideo, "video"},
|
||||||
{MediaTypeVideoAudio, "video_audio"},
|
{MediaTypeVideoAudio, "video_audio"},
|
||||||
{MediaType(0), "unknown"},
|
{MediaTypeAsk, "ask"},
|
||||||
{MediaType(99), "unknown"},
|
{MediaType(99), "unknown"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,21 +24,28 @@ type stepState struct {
|
|||||||
URL string
|
URL string
|
||||||
MediaInfo *domain.MediaInfo
|
MediaInfo *domain.MediaInfo
|
||||||
Step int
|
Step int
|
||||||
|
SelectedType string // "video", "audio", "ask", or "" when not yet chosen
|
||||||
MainFormatID string
|
MainFormatID string
|
||||||
MainFormat *domain.Format
|
MainFormat *domain.Format
|
||||||
SecondaryID string
|
SecondaryID string
|
||||||
SecondaryFmt *domain.Format
|
SecondaryFmt *domain.Format
|
||||||
Language string
|
Language string
|
||||||
Container string
|
|
||||||
FormatIDs []string
|
FormatIDs []string
|
||||||
PlaylistState *domain.PlaylistState
|
|
||||||
SearchState *domain.SearchState
|
SearchState *domain.SearchState
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stepStateTTL = 30 * time.Minute
|
||||||
|
|
||||||
func (h *Handler) getUserStepState(chatID int64) *stepState {
|
func (h *Handler) getUserStepState(chatID int64) *stepState {
|
||||||
h.stepStatesMu.Lock()
|
h.stepStatesMu.Lock()
|
||||||
defer h.stepStatesMu.Unlock()
|
defer h.stepStatesMu.Unlock()
|
||||||
return h.stepStates[chatID]
|
ss := h.stepStates[chatID]
|
||||||
|
if ss != nil && time.Since(ss.CreatedAt) > stepStateTTL {
|
||||||
|
delete(h.stepStates, chatID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ss
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
|
func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
|
||||||
@@ -52,6 +60,25 @@ func (h *Handler) clearStepState(chatID int64) {
|
|||||||
delete(h.stepStates, chatID)
|
delete(h.stepStates, chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripPlaylistParam removes the list parameter from YouTube/Music URLs.
|
||||||
|
// Playlists are not supported; this converts a video-with-playlist URL (e.g. a
|
||||||
|
// YouTube Music radio) to a plain video URL. Pure playlist URLs are rejected.
|
||||||
|
func stripPlaylistParam(rawURL string) (string, bool) {
|
||||||
|
// Reject pure playlist URLs (https://youtube.com/playlist?list=...)
|
||||||
|
if strings.Contains(rawURL, "/playlist") || strings.Contains(rawURL, "youtube.com/pL") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Strip the list query parameter from watch URLs
|
||||||
|
url := rawURL
|
||||||
|
if strings.Contains(url, "list=") {
|
||||||
|
// Remove &list=... or ?list=...
|
||||||
|
url = regexp.MustCompile(`[?&]list=[^&]+`).ReplaceAllString(url, "")
|
||||||
|
// Clean up trailing ? or & if the param was the only one
|
||||||
|
url = strings.TrimRight(url, "?&")
|
||||||
|
}
|
||||||
|
return url, true
|
||||||
|
}
|
||||||
|
|
||||||
// handleURLInput is the entry point when a user sends a URL or types in Download mode.
|
// handleURLInput is the entry point when a user sends a URL or types in Download mode.
|
||||||
func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) {
|
func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) {
|
||||||
url, ok := domain.ValidateURL(text)
|
url, ok := domain.ValidateURL(text)
|
||||||
@@ -60,6 +87,13 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip playlist parameter and reject pure playlists.
|
||||||
|
url, ok = stripPlaylistParam(url)
|
||||||
|
if !ok {
|
||||||
|
h.sendText(ctx, chatID, "Playlists are not supported. Send a single video/audio URL.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if job := h.getActiveJob(userID); job != nil {
|
if job := h.getActiveJob(userID); job != nil {
|
||||||
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
||||||
return
|
return
|
||||||
@@ -79,9 +113,7 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
{{Text: "Cancel", CallbackData: "pending_cancel"}},
|
{{Text: "Cancel", CallbackData: "pending_cancel"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
h.sendWithKB(ctx, chatID, "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", kb)
|
||||||
ChatID: chatID, Text: "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", ReplyMarkup: kb,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle))
|
h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle))
|
||||||
@@ -106,19 +138,14 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.IsPlaylist {
|
|
||||||
h.handlePlaylist(ctx, chatID, userID, url, info)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(info.Formats) == 0 {
|
if len(info.Formats) == 0 {
|
||||||
h.sendText(ctx, chatID, "No formats available for this URL.")
|
h.sendText(ctx, chatID, "No formats available for this URL.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject if user has already exhausted quota (limit of 0 = unlimited).
|
// Reject if user has already exhausted quota (limit of 0 = unlimited).
|
||||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
q, qErr := h.Repo.GetOrCreateQuotas(userID)
|
||||||
if err == nil {
|
if qErr == nil {
|
||||||
q = h.resetQuotasIfNeeded(q)
|
q = h.resetQuotasIfNeeded(q)
|
||||||
if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) ||
|
if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) ||
|
||||||
(q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) ||
|
(q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) ||
|
||||||
@@ -130,46 +157,175 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.sendMediaPreview(ctx, chatID, info)
|
||||||
|
|
||||||
|
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||||
|
if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk {
|
||||||
|
// Media type is set — check quality preference
|
||||||
|
var qualityPref string
|
||||||
|
switch prefs.DefaultMediaType {
|
||||||
|
case domain.MediaTypeVideo:
|
||||||
|
qualityPref = prefs.DefaultVideoFormat
|
||||||
|
case domain.MediaTypeAudio:
|
||||||
|
qualityPref = prefs.DefaultAudioFormat
|
||||||
|
}
|
||||||
|
if qualityPref != "" {
|
||||||
|
h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Quality is Ask — show filtered format picker
|
||||||
ss := &stepState{
|
ss := &stepState{
|
||||||
URL: url,
|
URL: url,
|
||||||
MediaInfo: info,
|
MediaInfo: info,
|
||||||
Step: 0,
|
Step: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.sendMediaPreview(ctx, chatID, info)
|
|
||||||
|
|
||||||
// Build unified format list: video formats first, then audio
|
|
||||||
deduped := dl.DeduplicateResolutions(info.Formats)
|
|
||||||
var videoFmts, audioFmts []domain.Format
|
|
||||||
for _, f := range deduped {
|
|
||||||
if f.HasVideo {
|
|
||||||
videoFmts = append(videoFmts, f)
|
|
||||||
} else {
|
|
||||||
audioFmts = append(audioFmts, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ss.FormatIDs = make([]string, 0, len(deduped)+1)
|
|
||||||
labels := make([]string, 0, len(deduped)+1)
|
|
||||||
ss.FormatIDs = append(ss.FormatIDs, "best")
|
|
||||||
labels = append(labels, "Best")
|
|
||||||
for _, f := range videoFmts {
|
|
||||||
ss.FormatIDs = append(ss.FormatIDs, f.ID)
|
|
||||||
labels = append(labels, formatLabelForDisplay(f))
|
|
||||||
}
|
|
||||||
for _, f := range audioFmts {
|
|
||||||
ss.FormatIDs = append(ss.FormatIDs, f.ID)
|
|
||||||
labels = append(labels, formatLabelForDisplay(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
h.setUserStepState(chatID, ss)
|
h.setUserStepState(chatID, ss)
|
||||||
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
h.showFormatsForType(ctx, chatID, userID, ss, prefs.DefaultMediaType.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ss := &stepState{
|
||||||
|
URL: url,
|
||||||
|
MediaInfo: info,
|
||||||
|
Step: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
h.setUserStepState(chatID, ss)
|
||||||
|
|
||||||
|
kb := mediaTypeKeyboard()
|
||||||
|
h.sendWithKB(ctx, chatID, "Select media type:", kb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSelectAndDownload picks the best matching format from preferences and
|
||||||
|
// skips the picker entirely.
|
||||||
|
func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo, prefs *domain.UserPreferences) {
|
||||||
|
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: "Processing..."})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("send processing message failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgID := msg.ID
|
||||||
|
|
||||||
|
ss := &stepState{
|
||||||
|
URL: url,
|
||||||
|
MediaInfo: info,
|
||||||
|
Step: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainID, secondaryID string
|
||||||
|
switch prefs.DefaultMediaType {
|
||||||
|
case domain.MediaTypeVideo:
|
||||||
|
mainID, secondaryID = autoSelectVideoFormat(info.Formats, prefs.DefaultVideoFormat)
|
||||||
|
case domain.MediaTypeAudio:
|
||||||
|
mainID = autoSelectAudioFormat(info.Formats, prefs.DefaultAudioFormat)
|
||||||
|
default:
|
||||||
|
mainID = "best"
|
||||||
|
}
|
||||||
|
|
||||||
|
if mainID == "" {
|
||||||
|
mainID = "best"
|
||||||
|
}
|
||||||
|
ss.MainFormatID = mainID
|
||||||
|
|
||||||
|
if mainID == "best" {
|
||||||
|
if info.IsAudioOnlyContent() {
|
||||||
|
ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true}
|
||||||
|
} else {
|
||||||
|
ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i := range info.Formats {
|
||||||
|
f := &info.Formats[i]
|
||||||
|
if f.ID == mainID {
|
||||||
|
ss.MainFormat = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if secondaryID != "" {
|
||||||
|
ss.SecondaryID = secondaryID
|
||||||
|
for i := range info.Formats {
|
||||||
|
f := &info.Formats[i]
|
||||||
|
if f.ID == secondaryID {
|
||||||
|
ss.SecondaryFmt = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showFormatsForType sends the format picker filtered by the given media type.
|
||||||
|
// Called when the user has a type preference but quality is Ask.
|
||||||
|
func (h *Handler) showFormatsForType(ctx context.Context, chatID int64, userID int64, ss *stepState, mediaType string) {
|
||||||
|
ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(ctx, chatID, "No formats available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ss.FormatIDs = ids
|
||||||
|
h.setUserStepState(chatID, ss)
|
||||||
|
|
||||||
|
labels := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
if id == "best" {
|
||||||
|
labels[i] = "Best"
|
||||||
|
} else {
|
||||||
|
f := findFormatByID(ss.MediaInfo.Formats, id)
|
||||||
|
if f != nil {
|
||||||
|
labels[i] = formatLabelForDisplay(*f)
|
||||||
|
} else {
|
||||||
|
labels[i] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kb := formatKeyboard("format_", labels, ids)
|
||||||
h.sendWithKB(ctx, chatID, "Select format:", kb)
|
h.sendWithKB(ctx, chatID, "Select format:", kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleTypeSelection processes the initial type picker (Video/Audio/Ask).
|
||||||
|
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, mediaType string) {
|
||||||
|
ss := h.getUserStepState(chatID)
|
||||||
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleTypeSelection", "chat_id", chatID, "user_id", userID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ss.SelectedType = mediaType
|
||||||
|
|
||||||
|
ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
h.editText(ctx, chatID, msgID, "No formats available for this type.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ss.FormatIDs = ids
|
||||||
|
h.setUserStepState(chatID, ss)
|
||||||
|
|
||||||
|
labels := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
if id == "best" {
|
||||||
|
labels[i] = "Best"
|
||||||
|
} else {
|
||||||
|
f := findFormatByID(ss.MediaInfo.Formats, id)
|
||||||
|
if f != nil {
|
||||||
|
labels[i] = formatLabelForDisplay(*f)
|
||||||
|
} else {
|
||||||
|
labels[i] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kb := formatKeyboard("format_", labels, ids)
|
||||||
|
h.editText(ctx, chatID, msgID, "Select format:", &kb)
|
||||||
|
}
|
||||||
|
|
||||||
// handleFormatSelection processes a format pick from the unified list.
|
// handleFormatSelection processes a format pick from the unified list.
|
||||||
func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) {
|
func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) {
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleFormatSelection", "chat_id", chatID, "user_id", userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ss.MainFormatID = formatID
|
ss.MainFormatID = formatID
|
||||||
@@ -183,7 +339,6 @@ func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID
|
|||||||
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range ss.MediaInfo.Formats {
|
for i := range ss.MediaInfo.Formats {
|
||||||
f := &ss.MediaInfo.Formats[i]
|
f := &ss.MediaInfo.Formats[i]
|
||||||
if f.ID == formatID {
|
if f.ID == formatID {
|
||||||
@@ -255,6 +410,7 @@ func (h *Handler) showSecondaryFormats(ctx context.Context, chatID int64, msgID
|
|||||||
func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) {
|
func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, msgID int, userID int64, formatID string) {
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleSecondarySelection", "chat_id", chatID, "user_id", userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,27 +436,29 @@ func (h *Handler) handleSecondarySelection(ctx context.Context, chatID int64, ms
|
|||||||
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
||||||
}
|
}
|
||||||
|
|
||||||
// advanceToLanguage moves to language selection, or skips to container if N/A.
|
// advanceToLanguage moves to language selection, or starts the download if only one language.
|
||||||
func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
func (h *Handler) advanceToLanguage(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
||||||
ss.Step = 2
|
ss.Step = 2
|
||||||
h.setUserStepState(chatID, ss)
|
h.setUserStepState(chatID, ss)
|
||||||
|
|
||||||
if len(ss.MediaInfo.Languages) > 1 {
|
if len(ss.MediaInfo.Languages) > 1 {
|
||||||
|
// Check if user has a default language and it's available.
|
||||||
|
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||||
|
if prefErr == nil && prefs.DefaultLanguage != "" {
|
||||||
|
for _, lang := range ss.MediaInfo.Languages {
|
||||||
|
if lang == prefs.DefaultLanguage {
|
||||||
|
ss.Language = lang
|
||||||
|
h.startDownload(ctx, chatID, msgID, userID, ss)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No match or no preference — show picker.
|
||||||
kb := languageKeyboard(ss.MediaInfo.Languages)
|
kb := languageKeyboard(ss.MediaInfo.Languages)
|
||||||
h.editText(ctx, chatID, msgID, "Select language:", &kb)
|
h.editText(ctx, chatID, msgID, "Select language:", &kb)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.advanceToContainer(ctx, chatID, msgID, userID, ss)
|
h.startDownload(ctx, chatID, msgID, userID, ss)
|
||||||
}
|
|
||||||
|
|
||||||
// advanceToContainer shows the container picker.
|
|
||||||
func (h *Handler) advanceToContainer(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
|
||||||
ss.Step = 3
|
|
||||||
h.setUserStepState(chatID, ss)
|
|
||||||
|
|
||||||
containers := dl.ContainerOptions(ss.determineMediaType())
|
|
||||||
kb := containerKeyboard(containers)
|
|
||||||
h.editText(ctx, chatID, msgID, "Select container format:", &kb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *stepState) determineMediaType() domain.MediaType {
|
func (ss *stepState) determineMediaType() domain.MediaType {
|
||||||
@@ -327,20 +485,15 @@ func (ss *stepState) buildFormatString() string {
|
|||||||
func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) {
|
func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) {
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleLanguageSelection", "chat_id", chatID, "user_id", userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ss.Language = language
|
ss.Language = language
|
||||||
h.advanceToContainer(ctx, chatID, msgID, userID, ss)
|
h.startDownload(ctx, chatID, msgID, userID, ss)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleContainerSelection processes the container choice and starts the download.
|
// startDownload creates the download job and begins the download process.
|
||||||
func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, msgID int, userID int64, container string) {
|
func (h *Handler) startDownload(ctx context.Context, chatID int64, msgID int, userID int64, ss *stepState) {
|
||||||
ss := h.getUserStepState(chatID)
|
|
||||||
if ss == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ss.Container = container
|
|
||||||
|
|
||||||
mediaType := ss.determineMediaType()
|
mediaType := ss.determineMediaType()
|
||||||
formatString := ss.buildFormatString()
|
formatString := ss.buildFormatString()
|
||||||
|
|
||||||
@@ -368,11 +521,11 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
|||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
SelectedFormat: format,
|
SelectedFormat: format,
|
||||||
FormatString: formatString,
|
FormatString: formatString,
|
||||||
Container: container,
|
|
||||||
Language: ss.Language,
|
Language: ss.Language,
|
||||||
Status: domain.StatusDownloading,
|
Status: domain.StatusDownloading,
|
||||||
Title: ss.MediaInfo.Title,
|
Title: ss.MediaInfo.Title,
|
||||||
FileSize: fileSize,
|
FileSize: fileSize,
|
||||||
|
MaxFileSize: h.maxFileSize,
|
||||||
Done: make(chan struct{}),
|
Done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +545,6 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Acquire semaphore slot or wait for cancellation
|
|
||||||
select {
|
select {
|
||||||
case h.downloadSem <- struct{}{}:
|
case h.downloadSem <- struct{}{}:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -401,9 +553,13 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
|||||||
defer func() { <-h.downloadSem }()
|
defer func() { <-h.downloadSem }()
|
||||||
|
|
||||||
kb := progressKeyboard(0, "", "")
|
kb := progressKeyboard(0, "", "")
|
||||||
|
progressHeader := "Downloading: " + job.Title
|
||||||
|
if job.ProgressPrefix != "" {
|
||||||
|
progressHeader = job.ProgressPrefix + "\n" + progressHeader
|
||||||
|
}
|
||||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||||
ChatID: job.ChatID,
|
ChatID: job.ChatID,
|
||||||
Text: fmt.Sprintf("Downloading: %s", job.Title),
|
Text: progressHeader,
|
||||||
ReplyMarkup: &kb,
|
ReplyMarkup: &kb,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -414,21 +570,35 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
|||||||
|
|
||||||
updates, err := h.YtDlp.StartDownload(job, h.downloadDir)
|
updates, err := h.YtDlp.StartDownload(job, h.downloadDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", err.Error()), nil)
|
errMsg := err.Error()
|
||||||
|
if job.StderrLog != "" {
|
||||||
|
if lines := strings.SplitN(job.StderrLog, "\n", 2); len(lines) > 0 && lines[0] != "" {
|
||||||
|
errMsg = strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", errMsg), nil)
|
||||||
job.Status = domain.StatusFailed
|
job.Status = domain.StatusFailed
|
||||||
h.activeJobsMu.Lock()
|
h.clearActiveJob(job.UserID)
|
||||||
delete(h.activeJobs, job.UserID)
|
|
||||||
h.activeJobsMu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.trackDownloadProgress(ctx, job, updates)
|
||||||
|
|
||||||
|
if job.Status == domain.StatusCompleted {
|
||||||
|
h.handleDownloadCompletion(ctx, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.clearActiveJob(job.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackDownloadProgress reads progress updates from yt-dlp and updates the Telegram message.
|
||||||
|
func (h *Handler) trackDownloadProgress(ctx context.Context, job *domain.DownloadJob, updates <-chan *domain.DownloadJob) {
|
||||||
lastProgress := 0.0
|
lastProgress := 0.0
|
||||||
loop:
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case upd, ok := <-updates:
|
case upd, ok := <-updates:
|
||||||
if !ok {
|
if !ok {
|
||||||
break loop
|
return
|
||||||
}
|
}
|
||||||
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
|
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
|
||||||
|
|
||||||
@@ -441,25 +611,30 @@ loop:
|
|||||||
|
|
||||||
if upd.Status == domain.StatusCancelled {
|
if upd.Status == domain.StatusCancelled {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
||||||
h.activeJobsMu.Lock()
|
|
||||||
delete(h.activeJobs, job.UserID)
|
|
||||||
h.activeJobsMu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if upd.Status == domain.StatusFailed {
|
if upd.Status == domain.StatusFailed {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
||||||
h.activeJobsMu.Lock()
|
|
||||||
delete(h.activeJobs, job.UserID)
|
|
||||||
h.activeJobsMu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if upd.Progress > 0 && lastProgress == 0 {
|
if upd.Status == domain.StatusCompleted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastProgress == 0 {
|
||||||
lastProgress = upd.Progress
|
lastProgress = upd.Progress
|
||||||
} else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted {
|
|
||||||
continue
|
|
||||||
} else {
|
} else {
|
||||||
|
diff := upd.Progress - lastProgress
|
||||||
|
// Skip small changes (< threshold) in either direction to avoid spamming edits.
|
||||||
|
// HLS streams often emit a fake 100% initial estimate then real values.
|
||||||
|
if diff >= 0 && diff < domain.ProgressThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diff < 0 && -diff < domain.ProgressThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
lastProgress = upd.Progress
|
lastProgress = upd.Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,34 +644,41 @@ loop:
|
|||||||
}
|
}
|
||||||
|
|
||||||
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
|
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
|
||||||
text := fmt.Sprintf("Downloading: %s", job.Title)
|
text := "Downloading: " + job.Title
|
||||||
|
if job.ProgressPrefix != "" {
|
||||||
|
text = job.ProgressPrefix + "\n" + text
|
||||||
|
}
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
|
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
|
||||||
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
dl.CancelDownload(job)
|
dl.CancelDownload(job)
|
||||||
for range updates {
|
for range updates {
|
||||||
}
|
}
|
||||||
break loop
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.activeJobsMu.Lock()
|
// handleDownloadCompletion sends the downloaded file and records history.
|
||||||
delete(h.activeJobs, job.UserID)
|
// Uses context.Background() for Telegram notifications so that cancellation
|
||||||
h.activeJobsMu.Unlock()
|
// of the download context does not prevent the file from being delivered.
|
||||||
|
func (h *Handler) handleDownloadCompletion(ctx context.Context, job *domain.DownloadJob) {
|
||||||
h.cancelFuncsMu.Lock()
|
|
||||||
delete(h.cancelFuncs, job.UserID)
|
|
||||||
h.cancelFuncsMu.Unlock()
|
|
||||||
|
|
||||||
if job.Status == domain.StatusCompleted {
|
|
||||||
pattern := filepath.Join(h.downloadDir, job.ID+"_*")
|
pattern := filepath.Join(h.downloadDir, job.ID+"_*")
|
||||||
files, err := filepath.Glob(pattern)
|
files, err := filepath.Glob(pattern)
|
||||||
if err == nil && len(files) > 0 {
|
if err != nil || len(files) == 0 {
|
||||||
|
h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed but file not found.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
f := files[0]
|
f := files[0]
|
||||||
fileInfo, err := os.Stat(f)
|
fileInfo, err := os.Stat(f)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
|
h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed but file not found.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if fileInfo.Size() > h.maxFileSize {
|
if fileInfo.Size() > h.maxFileSize {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID,
|
h.editText(context.Background(), job.ChatID, job.MessageID,
|
||||||
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
|
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
|
||||||
os.Remove(f)
|
os.Remove(f)
|
||||||
return
|
return
|
||||||
@@ -507,26 +689,21 @@ loop:
|
|||||||
h.applyQuota(job.UserID, job.FileSize)
|
h.applyQuota(job.UserID, job.FileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
formatID := job.SelectedFormat.ID
|
h.sendDocument(context.Background(), job.ChatID, f, job.Title)
|
||||||
h.sendDocument(ctx, job.ChatID, f, job.Title)
|
|
||||||
os.Remove(f)
|
os.Remove(f)
|
||||||
|
|
||||||
_ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
|
if err := h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
|
||||||
URL: job.URL,
|
URL: job.URL,
|
||||||
Title: job.Title,
|
Title: job.Title,
|
||||||
MediaType: job.MediaType.String(),
|
MediaType: job.MediaType.String(),
|
||||||
FormatID: formatID,
|
FormatID: job.SelectedFormat.ID,
|
||||||
Container: job.Container,
|
|
||||||
FileSize: job.FileSize,
|
FileSize: job.FileSize,
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
})
|
}); err != nil {
|
||||||
|
slog.Error("add history failed", "user_id", job.UserID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download completed!", nil)
|
h.editText(context.Background(), job.ChatID, job.MessageID, "Download completed!", nil)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, title string) {
|
func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, title string) {
|
||||||
@@ -579,35 +756,39 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c
|
|||||||
defer h.answerCb(ctx, cbID)
|
defer h.answerCb(ctx, cbID)
|
||||||
ss := h.getUserStepState(chatID)
|
ss := h.getUserStepState(chatID)
|
||||||
if ss == nil {
|
if ss == nil {
|
||||||
|
slog.Warn("nil stepState in handleBackStep", "chat_id", chatID, "user_id", userID)
|
||||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If on the format list with a selected type, go back to type picker.
|
||||||
|
if ss.Step == 0 && ss.SelectedType != "" {
|
||||||
|
ss.SelectedType = ""
|
||||||
|
h.setUserStepState(chatID, ss)
|
||||||
|
kb := mediaTypeKeyboard()
|
||||||
|
h.editText(ctx, chatID, msgID, "Select media type:", &kb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ss.Step--
|
ss.Step--
|
||||||
switch ss.Step {
|
switch ss.Step {
|
||||||
case -1:
|
case -1:
|
||||||
h.clearStepState(chatID)
|
h.clearStepState(chatID)
|
||||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||||
case 0:
|
case 0:
|
||||||
// Re-show format list
|
ss.FormatIDs, _ = buildFilteredFormatList(ss.MediaInfo.Formats, "")
|
||||||
deduped := dl.DeduplicateResolutions(ss.MediaInfo.Formats)
|
labels := make([]string, len(ss.FormatIDs))
|
||||||
var videoFmts, audioFmts []domain.Format
|
for i, id := range ss.FormatIDs {
|
||||||
for _, f := range deduped {
|
if id == "best" {
|
||||||
if f.HasVideo {
|
labels[i] = "Best"
|
||||||
videoFmts = append(videoFmts, f)
|
|
||||||
} else {
|
} else {
|
||||||
audioFmts = append(audioFmts, f)
|
f := findFormatByID(ss.MediaInfo.Formats, id)
|
||||||
|
if f != nil {
|
||||||
|
labels[i] = formatLabelForDisplay(*f)
|
||||||
|
} else {
|
||||||
|
labels[i] = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ss.FormatIDs = []string{"best"}
|
|
||||||
labels := []string{"Best"}
|
|
||||||
for _, f := range videoFmts {
|
|
||||||
ss.FormatIDs = append(ss.FormatIDs, f.ID)
|
|
||||||
labels = append(labels, formatLabelForDisplay(f))
|
|
||||||
}
|
|
||||||
for _, f := range audioFmts {
|
|
||||||
ss.FormatIDs = append(ss.FormatIDs, f.ID)
|
|
||||||
labels = append(labels, formatLabelForDisplay(f))
|
|
||||||
}
|
}
|
||||||
h.setUserStepState(chatID, ss)
|
h.setUserStepState(chatID, ss)
|
||||||
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
||||||
@@ -644,7 +825,204 @@ func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID
|
|||||||
"Send me the URL to download:", nil)
|
"Send me the URL to download:", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildFormatList returns a deduplicated format ID list with "best" first, then video, then audio.
|
||||||
|
func buildFormatList(formats []domain.Format) ([]string, error) {
|
||||||
|
deduped := dl.DeduplicateResolutions(formats)
|
||||||
|
ids := make([]string, 0, len(deduped)+1)
|
||||||
|
ids = append(ids, "best")
|
||||||
|
for _, f := range deduped {
|
||||||
|
if f.HasVideo {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range deduped {
|
||||||
|
if !f.HasVideo {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFilteredFormatList returns format IDs filtered by media type for the type picker.
|
||||||
|
// When mediaType is "video", only formats with video are returned.
|
||||||
|
// When mediaType is "audio", only audio-only formats are returned.
|
||||||
|
// When empty or "ask", all formats are returned (current behavior).
|
||||||
|
func buildFilteredFormatList(formats []domain.Format, mediaType string) ([]string, error) {
|
||||||
|
deduped := dl.DeduplicateResolutions(formats)
|
||||||
|
ids := make([]string, 0, len(deduped)+1)
|
||||||
|
ids = append(ids, "best")
|
||||||
|
|
||||||
|
switch mediaType {
|
||||||
|
case "video":
|
||||||
|
for _, f := range deduped {
|
||||||
|
if f.HasVideo {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
for _, f := range deduped {
|
||||||
|
if f.HasAudio && f.IsAudioOnly {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for _, f := range deduped {
|
||||||
|
if f.HasVideo {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range deduped {
|
||||||
|
if !f.HasVideo {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSelectVideoFormat finds the closest video format to the preferred resolution.
|
||||||
|
// Returns the main format ID and optionally a secondary audio ID if the best match
|
||||||
|
// is video-only. Prefers formats at or above the target resolution, falling back to
|
||||||
|
// the closest below. Returns ("best", "") if the preference is "best" or no match.
|
||||||
|
func autoSelectVideoFormat(formats []domain.Format, preferredID string) (mainID, secondaryID string) {
|
||||||
|
if preferredID == "" || preferredID == "best" {
|
||||||
|
return "best", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetHeight int
|
||||||
|
if n, err := fmt.Sscanf(preferredID, "%dp", &targetHeight); err != nil || n != 1 {
|
||||||
|
return "best", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestFmt *domain.Format
|
||||||
|
bestDiff := math.MaxInt32
|
||||||
|
|
||||||
|
for i := range formats {
|
||||||
|
f := &formats[i]
|
||||||
|
if !f.HasVideo || f.Height == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diff := f.Height - targetHeight
|
||||||
|
if diff >= 0 && diff < bestDiff {
|
||||||
|
bestDiff = diff
|
||||||
|
bestFmt = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestFmt == nil {
|
||||||
|
bestDiff = math.MaxInt32
|
||||||
|
for i := range formats {
|
||||||
|
f := &formats[i]
|
||||||
|
if !f.HasVideo || f.Height == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diff := targetHeight - f.Height
|
||||||
|
if diff >= 0 && diff < bestDiff {
|
||||||
|
bestDiff = diff
|
||||||
|
bestFmt = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestFmt == nil {
|
||||||
|
return "best", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
mainID = bestFmt.ID
|
||||||
|
|
||||||
|
if bestFmt.HasVideo && !bestFmt.HasAudio {
|
||||||
|
for i := range formats {
|
||||||
|
f := &formats[i]
|
||||||
|
if f.HasAudio && f.IsAudioOnly {
|
||||||
|
secondaryID = f.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSelectAudioFormat finds the closest audio format to the preferred bitrate.
|
||||||
|
// Returns the format ID, or "best" if the preference is "best" or no match.
|
||||||
|
func autoSelectAudioFormat(formats []domain.Format, preferredID string) string {
|
||||||
|
if preferredID == "" || preferredID == "best" {
|
||||||
|
return "best"
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetBitrate int
|
||||||
|
if n, err := fmt.Sscanf(preferredID, "%dk", &targetBitrate); err != nil || n != 1 {
|
||||||
|
return "best"
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestFmt *domain.Format
|
||||||
|
bestDiff := math.MaxInt32
|
||||||
|
|
||||||
|
for i := range formats {
|
||||||
|
f := &formats[i]
|
||||||
|
if !f.HasAudio || !f.IsAudioOnly {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var bitrate int
|
||||||
|
if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diff := bitrate - targetBitrate
|
||||||
|
if diff >= 0 && diff < bestDiff {
|
||||||
|
bestDiff = diff
|
||||||
|
bestFmt = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestFmt == nil {
|
||||||
|
bestDiff = math.MaxInt32
|
||||||
|
for i := range formats {
|
||||||
|
f := &formats[i]
|
||||||
|
if !f.HasAudio || !f.IsAudioOnly {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var bitrate int
|
||||||
|
if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diff := targetBitrate - bitrate
|
||||||
|
if diff >= 0 && diff < bestDiff {
|
||||||
|
bestDiff = diff
|
||||||
|
bestFmt = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestFmt == nil {
|
||||||
|
return "best"
|
||||||
|
}
|
||||||
|
return bestFmt.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFormatByID returns the format with the given ID from the list, or nil.
|
||||||
|
func findFormatByID(formats []domain.Format, id string) *domain.Format {
|
||||||
|
for i := range formats {
|
||||||
|
if formats[i].ID == id {
|
||||||
|
return &formats[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func formatLabelForDisplay(f domain.Format) string {
|
func formatLabelForDisplay(f domain.Format) string {
|
||||||
|
if f.IsAudioOnly {
|
||||||
|
label := f.Bitrate
|
||||||
|
if label == "" {
|
||||||
|
label = f.ID
|
||||||
|
if f.Extension != "" {
|
||||||
|
label += " (" + f.Extension + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Filesize > 0 {
|
||||||
|
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
if f.Resolution != "" {
|
if f.Resolution != "" {
|
||||||
label := f.Resolution
|
label := f.Resolution
|
||||||
if f.Filesize > 0 {
|
if f.Filesize > 0 {
|
||||||
@@ -652,6 +1030,13 @@ func formatLabelForDisplay(f domain.Format) string {
|
|||||||
}
|
}
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
if f.Height > 0 {
|
||||||
|
label := fmt.Sprintf("%dp", f.Height)
|
||||||
|
if f.Filesize > 0 {
|
||||||
|
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
if f.Bitrate != "" {
|
if f.Bitrate != "" {
|
||||||
label := f.Bitrate
|
label := f.Bitrate
|
||||||
if f.Filesize > 0 {
|
if f.Filesize > 0 {
|
||||||
@@ -659,19 +1044,12 @@ func formatLabelForDisplay(f domain.Format) string {
|
|||||||
}
|
}
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
// Audio-only with no bitrate: show format ID + extension
|
|
||||||
if f.IsAudioOnly {
|
|
||||||
label := f.ID
|
label := f.ID
|
||||||
if f.Extension != "" {
|
if f.Extension != "" {
|
||||||
label += " (" + f.Extension + ")"
|
label += " (" + f.Extension + ")"
|
||||||
}
|
}
|
||||||
if f.Filesize > 0 {
|
|
||||||
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
|
|
||||||
}
|
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
return f.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendMediaPreview sends a thumbnail and metadata info before the selection flow.
|
// sendMediaPreview sends a thumbnail and metadata info before the selection flow.
|
||||||
func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) {
|
func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) {
|
||||||
@@ -721,7 +1099,14 @@ func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *doma
|
|||||||
|
|
||||||
// downloadFile downloads a URL to a temp file in the given directory and returns the path.
|
// downloadFile downloads a URL to a temp file in the given directory and returns the path.
|
||||||
func downloadFile(url, dir string) (string, error) {
|
func downloadFile(url, dir string) (string, error) {
|
||||||
resp, err := http.Get(url)
|
tr := &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: tr,
|
||||||
|
}
|
||||||
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("http get: %w", err)
|
return "", fmt.Errorf("http get: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
|||||||
defer h.answerCb(ctx, cbID)
|
defer h.answerCb(ctx, cbID)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case strings.HasPrefix(data, "type_"):
|
||||||
|
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
|
||||||
case strings.HasPrefix(data, "format_"):
|
case strings.HasPrefix(data, "format_"):
|
||||||
h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:])
|
h.handleFormatSelection(ctx, chatID, msgID, userID, data[7:])
|
||||||
case strings.HasPrefix(data, "secondary_"):
|
case strings.HasPrefix(data, "secondary_"):
|
||||||
@@ -280,23 +282,13 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
|||||||
case strings.HasPrefix(data, "lang_"):
|
case strings.HasPrefix(data, "lang_"):
|
||||||
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
||||||
case strings.HasPrefix(data, "container_"):
|
case strings.HasPrefix(data, "container_"):
|
||||||
h.handleContainerSelection(ctx, chatID, msgID, userID, data[10:])
|
// container selection was removed — yt-dlp handles it
|
||||||
case strings.HasPrefix(data, "pl_page_"):
|
|
||||||
h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:])
|
|
||||||
case strings.HasPrefix(data, "pl_entry_"):
|
|
||||||
h.handlePlaylistEntry(ctx, chatID, msgID, userID, data[9:])
|
|
||||||
case data == "pl_select_all":
|
|
||||||
h.handlePlaylistSelectAll(ctx, chatID, msgID, userID)
|
|
||||||
case data == "pl_confirm":
|
|
||||||
h.handlePlaylistConfirm(ctx, chatID, msgID, userID)
|
|
||||||
case strings.HasPrefix(data, "delaccount_"):
|
case strings.HasPrefix(data, "delaccount_"):
|
||||||
if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 {
|
if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 {
|
||||||
h.deleteAccountConfirm(ctx, chatID, msgID, uid)
|
h.deleteAccountConfirm(ctx, chatID, msgID, uid)
|
||||||
}
|
}
|
||||||
case data == "back_quality":
|
case data == "back_quality":
|
||||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality")
|
h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality")
|
||||||
case data == "back_container":
|
|
||||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, "container")
|
|
||||||
case data == "back_settings":
|
case data == "back_settings":
|
||||||
h.backToSettings(ctx, chatID, msgID, userID)
|
h.backToSettings(ctx, chatID, msgID, userID)
|
||||||
case strings.HasPrefix(data, "settings_set_"):
|
case strings.HasPrefix(data, "settings_set_"):
|
||||||
@@ -466,21 +458,15 @@ func formatBytes(bytes int64) string {
|
|||||||
if bytes < unit {
|
if bytes < unit {
|
||||||
return strconv.FormatInt(bytes, 10) + "B"
|
return strconv.FormatInt(bytes, 10) + "B"
|
||||||
}
|
}
|
||||||
div, exp := int64(unit), 0
|
units := []string{"KB", "MB", "GB", "TB"}
|
||||||
for n := bytes / unit; n >= unit; n /= unit {
|
size := float64(bytes)
|
||||||
div *= unit
|
for _, u := range units {
|
||||||
exp++
|
size /= unit
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%.1f%s", size, u)
|
||||||
}
|
}
|
||||||
switch exp {
|
|
||||||
case 0:
|
|
||||||
return fmt.Sprintf("%.1fKB", float64(bytes)/float64(div))
|
|
||||||
case 1:
|
|
||||||
return fmt.Sprintf("%.1fMB", float64(bytes)/float64(div))
|
|
||||||
case 2:
|
|
||||||
return fmt.Sprintf("%.1fGB", float64(bytes)/float64(div))
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%.1fTB", float64(bytes)/float64(div))
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("%.1fTB", size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) generateJobID() string {
|
func (h *Handler) generateJobID() string {
|
||||||
@@ -573,6 +559,7 @@ func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int,
|
|||||||
func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
||||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
q, err := h.Repo.GetOrCreateQuotas(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("get quotas for apply", "user_id", userID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q = h.resetQuotasIfNeeded(q)
|
q = h.resetQuotasIfNeeded(q)
|
||||||
@@ -583,7 +570,9 @@ func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
|||||||
q.DailyUsedMB += sizeMB
|
q.DailyUsedMB += sizeMB
|
||||||
q.WeeklyUsedMB += sizeMB
|
q.WeeklyUsedMB += sizeMB
|
||||||
q.MonthlyUsedMB += sizeMB
|
q.MonthlyUsedMB += sizeMB
|
||||||
_ = h.Repo.UpdateQuotas(userID, q)
|
if err := h.Repo.UpdateQuotas(userID, q); err != nil {
|
||||||
|
slog.Error("update quotas", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetQuotasIfNeeded resets quota counters if the period has changed.
|
// resetQuotasIfNeeded resets quota counters if the period has changed.
|
||||||
|
|||||||
@@ -6,6 +6,126 @@ import (
|
|||||||
"uptodownBot/internal/domain"
|
"uptodownBot/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBuildFormatList(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "1", HasVideo: true, HasAudio: false, Resolution: "1920x1080"},
|
||||||
|
{ID: "2", HasVideo: false, HasAudio: true, Bitrate: "128k"},
|
||||||
|
{ID: "3", HasVideo: true, HasAudio: false, Resolution: "1280x720"},
|
||||||
|
{ID: "4", HasVideo: true, HasAudio: true, Resolution: "640x480"},
|
||||||
|
}
|
||||||
|
ids, err := buildFormatList(formats)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildFormatList() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
t.Fatal("buildFormatList() returned empty list")
|
||||||
|
}
|
||||||
|
if ids[0] != "best" {
|
||||||
|
t.Errorf("first ID = %q, want best", ids[0])
|
||||||
|
}
|
||||||
|
// Best, then all video formats, then all audio-only
|
||||||
|
wantVideo := map[string]bool{"1": true, "3": true, "4": true}
|
||||||
|
wantAudio := map[string]bool{"2": true}
|
||||||
|
seenAudio := false
|
||||||
|
for i := 1; i < len(ids); i++ {
|
||||||
|
if wantVideo[ids[i]] {
|
||||||
|
if seenAudio {
|
||||||
|
t.Errorf("video format %q found after audio-only formats", ids[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wantAudio[ids[i]] {
|
||||||
|
seenAudio = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFormatByID(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "1", Resolution: "1920x1080"},
|
||||||
|
{ID: "2", Bitrate: "128k"},
|
||||||
|
}
|
||||||
|
f := findFormatByID(formats, "1")
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("findFormatByID(1) = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if f.ID != "1" {
|
||||||
|
t.Errorf("findFormatByID(1).ID = %q, want 1", f.ID)
|
||||||
|
}
|
||||||
|
f = findFormatByID(formats, "nonexistent")
|
||||||
|
if f != nil {
|
||||||
|
t.Errorf("findFormatByID(nonexistent) = %+v, want nil", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepStateDetermineMediaType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ss *stepState
|
||||||
|
want domain.MediaType
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil main format",
|
||||||
|
ss: &stepState{},
|
||||||
|
want: domain.MediaTypeVideoAudio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audio only no secondary",
|
||||||
|
ss: &stepState{MainFormat: &domain.Format{IsAudioOnly: true}},
|
||||||
|
want: domain.MediaTypeAudio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audio only with secondary",
|
||||||
|
ss: &stepState{MainFormat: &domain.Format{IsAudioOnly: true}, SecondaryFmt: &domain.Format{HasVideo: true}},
|
||||||
|
want: domain.MediaTypeVideoAudio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has video, has audio",
|
||||||
|
ss: &stepState{MainFormat: &domain.Format{HasVideo: true, HasAudio: true}},
|
||||||
|
want: domain.MediaTypeVideoAudio,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.ss.determineMediaType()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("determineMediaType() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepStateBuildFormatString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ss *stepState
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "best format",
|
||||||
|
ss: &stepState{MainFormatID: "best"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single format",
|
||||||
|
ss: &stepState{MainFormatID: "137"},
|
||||||
|
want: "137",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combined format",
|
||||||
|
ss: &stepState{MainFormatID: "137", SecondaryID: "140", SecondaryFmt: &domain.Format{ID: "140"}},
|
||||||
|
want: "137+140",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.ss.buildFormatString()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("buildFormatString() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatBytes(t *testing.T) {
|
func TestFormatBytes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input int64
|
input int64
|
||||||
@@ -49,6 +169,209 @@ func TestFormatDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildFilteredFormatList(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "1", HasVideo: true, HasAudio: false, Resolution: "1920x1080"},
|
||||||
|
{ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
|
||||||
|
{ID: "3", HasVideo: true, HasAudio: false, Resolution: "1280x720"},
|
||||||
|
{ID: "4", HasVideo: true, HasAudio: true, Resolution: "640x480"},
|
||||||
|
{ID: "5", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ask type returns all formats", func(t *testing.T) {
|
||||||
|
ids, err := buildFilteredFormatList(formats, "ask")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildFilteredFormatList(ask) error: %v", err)
|
||||||
|
}
|
||||||
|
if ids[0] != "best" {
|
||||||
|
t.Errorf("first ID = %q, want best", ids[0])
|
||||||
|
}
|
||||||
|
// Should include all non-best IDs
|
||||||
|
expectedIDs := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true}
|
||||||
|
for _, id := range ids[1:] {
|
||||||
|
if !expectedIDs[id] {
|
||||||
|
t.Errorf("unexpected ID %q in ask list", id)
|
||||||
|
}
|
||||||
|
delete(expectedIDs, id)
|
||||||
|
}
|
||||||
|
if len(expectedIDs) > 0 {
|
||||||
|
t.Errorf("missing IDs in ask list: %v", expectedIDs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("video type only returns video formats", func(t *testing.T) {
|
||||||
|
ids, err := buildFilteredFormatList(formats, "video")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildFilteredFormatList(video) error: %v", err)
|
||||||
|
}
|
||||||
|
if ids[0] != "best" {
|
||||||
|
t.Errorf("first ID = %q, want best", ids[0])
|
||||||
|
}
|
||||||
|
for _, id := range ids[1:] {
|
||||||
|
if id == "2" || id == "5" {
|
||||||
|
t.Errorf("video list contains audio-only ID %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("audio type only returns audio-only formats", func(t *testing.T) {
|
||||||
|
ids, err := buildFilteredFormatList(formats, "audio")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildFilteredFormatList(audio) error: %v", err)
|
||||||
|
}
|
||||||
|
if ids[0] != "best" {
|
||||||
|
t.Errorf("first ID = %q, want best", ids[0])
|
||||||
|
}
|
||||||
|
for _, id := range ids[1:] {
|
||||||
|
if id == "1" || id == "3" || id == "4" {
|
||||||
|
t.Errorf("audio list contains video ID %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default (empty) returns all formats", func(t *testing.T) {
|
||||||
|
ids, err := buildFilteredFormatList(formats, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildFilteredFormatList('') error: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 6 { // best + 5 formats
|
||||||
|
t.Errorf("got %d IDs, want 6", len(ids))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoSelectVideoFormat(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "137", HasVideo: true, HasAudio: false, Height: 1080, Width: 1920, Resolution: "1920x1080"},
|
||||||
|
{ID: "136", HasVideo: true, HasAudio: false, Height: 720, Width: 1280, Resolution: "1280x720"},
|
||||||
|
{ID: "135", HasVideo: true, HasAudio: false, Height: 480, Width: 854, Resolution: "854x480"},
|
||||||
|
{ID: "247", HasVideo: true, HasAudio: true, Height: 1080, Width: 1920, Resolution: "1920x1080"},
|
||||||
|
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("best returns best, no secondary", func(t *testing.T) {
|
||||||
|
mainID, secondaryID := autoSelectVideoFormat(formats, "best")
|
||||||
|
if mainID != "best" {
|
||||||
|
t.Errorf("mainID = %q, want best", mainID)
|
||||||
|
}
|
||||||
|
if secondaryID != "" {
|
||||||
|
t.Errorf("secondaryID = %q, want empty", secondaryID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty returns best", func(t *testing.T) {
|
||||||
|
mainID, _ := autoSelectVideoFormat(formats, "")
|
||||||
|
if mainID != "best" {
|
||||||
|
t.Errorf("mainID = %q, want best", mainID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exact match 1080p", func(t *testing.T) {
|
||||||
|
mainID, _ := autoSelectVideoFormat(formats, "1080p")
|
||||||
|
if mainID != "137" && mainID != "247" {
|
||||||
|
t.Errorf("mainID = %q, want 137 or 247", mainID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prefer above 360p", func(t *testing.T) {
|
||||||
|
mainID, _ := autoSelectVideoFormat(formats, "360p")
|
||||||
|
// 480p (135) is the closest above 360p; below is none
|
||||||
|
if mainID != "135" {
|
||||||
|
t.Errorf("mainID = %q, want 135 (480p is closest above 360p)", mainID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exact match 720p", func(t *testing.T) {
|
||||||
|
mainID, _ := autoSelectVideoFormat(formats, "720p")
|
||||||
|
// 720p (136) is an exact match
|
||||||
|
if mainID != "136" {
|
||||||
|
t.Errorf("mainID = %q, want 136 (exact match for 720p)", mainID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("above 1080p takes 1080p as closest below", func(t *testing.T) {
|
||||||
|
mainID, _ := autoSelectVideoFormat(formats, "2160p")
|
||||||
|
// No 4K format, should fall back to 1080p (closest below)
|
||||||
|
if mainID != "137" && mainID != "247" {
|
||||||
|
t.Errorf("mainID = %q, want 137 or 247 (1080p is closest below 2160p)", mainID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("video-only picks best audio secondary", func(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "137", HasVideo: true, HasAudio: false, Height: 1080},
|
||||||
|
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
|
||||||
|
}
|
||||||
|
_, secondaryID := autoSelectVideoFormat(formats, "1080p")
|
||||||
|
if secondaryID != "140" {
|
||||||
|
t.Errorf("secondaryID = %q, want 140", secondaryID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no match returns best", func(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true},
|
||||||
|
}
|
||||||
|
mainID, _ := autoSelectVideoFormat(formats, "1080p")
|
||||||
|
if mainID != "best" {
|
||||||
|
t.Errorf("mainID = %q, want best", mainID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoSelectAudioFormat(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
|
||||||
|
{ID: "256", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "256k"},
|
||||||
|
{ID: "320", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("best returns best", func(t *testing.T) {
|
||||||
|
id := autoSelectAudioFormat(formats, "best")
|
||||||
|
if id != "best" {
|
||||||
|
t.Errorf("got %q, want best", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty returns best", func(t *testing.T) {
|
||||||
|
id := autoSelectAudioFormat(formats, "")
|
||||||
|
if id != "best" {
|
||||||
|
t.Errorf("got %q, want best", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exact match 256k", func(t *testing.T) {
|
||||||
|
id := autoSelectAudioFormat(formats, "256k")
|
||||||
|
if id != "256" {
|
||||||
|
t.Errorf("got %q, want 256", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prefer above 192k", func(t *testing.T) {
|
||||||
|
id := autoSelectAudioFormat(formats, "192k")
|
||||||
|
if id != "256" {
|
||||||
|
t.Errorf("got %q, want 256 (closest above 192k)", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("above highest takes highest below", func(t *testing.T) {
|
||||||
|
id := autoSelectAudioFormat(formats, "384k")
|
||||||
|
if id != "320" {
|
||||||
|
t.Errorf("got %q, want 320 (closest below 384k)", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no match returns best", func(t *testing.T) {
|
||||||
|
formats := []domain.Format{
|
||||||
|
{ID: "1", HasVideo: true, HasAudio: true},
|
||||||
|
}
|
||||||
|
id := autoSelectAudioFormat(formats, "128k")
|
||||||
|
if id != "best" {
|
||||||
|
t.Errorf("got %q, want best", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatLabelForDisplay(t *testing.T) {
|
func TestFormatLabelForDisplay(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
f domain.Format
|
f domain.Format
|
||||||
@@ -74,6 +397,18 @@ func TestFormatLabelForDisplay(t *testing.T) {
|
|||||||
f: domain.Format{ID: "137"},
|
f: domain.Format{ID: "137"},
|
||||||
want: "137",
|
want: "137",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{IsAudioOnly: true, Bitrate: "128k", Filesize: 1048576, Resolution: "audio only"},
|
||||||
|
want: "128k (1.0MB)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{IsAudioOnly: true, Bitrate: "128k", Resolution: "audio only"},
|
||||||
|
want: "128k",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{IsAudioOnly: true, ID: "140", Extension: "m4a", Filesize: 1048576, Resolution: "audio only"},
|
||||||
|
want: "140 (m4a) (1.0MB)",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := formatLabelForDisplay(tt.f)
|
got := formatLabelForDisplay(tt.f)
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
||||||
"uptodownBot/internal/domain"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func mainKeyboard() models.InlineKeyboardMarkup {
|
func mainKeyboard() models.InlineKeyboardMarkup {
|
||||||
@@ -66,30 +63,11 @@ func languageKeyboard(languages []string) models.InlineKeyboardMarkup {
|
|||||||
return formatKeyboard("lang_", languages, languages)
|
return formatKeyboard("lang_", languages, languages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// containerKeyboard builds keyboard for container format selection.
|
|
||||||
func containerKeyboard(containers []string) models.InlineKeyboardMarkup {
|
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
|
||||||
row := []models.InlineKeyboardButton{}
|
|
||||||
for i, c := range containers {
|
|
||||||
data := "container_" + c
|
|
||||||
row = append(row, models.InlineKeyboardButton{Text: strings.ToUpper(c), CallbackData: data})
|
|
||||||
if len(row) == 3 || i == len(containers)-1 {
|
|
||||||
rows = append(rows, row)
|
|
||||||
row = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: "Back", CallbackData: "back_step"},
|
|
||||||
{Text: "Cancel", CallbackData: "cancel_dl"},
|
|
||||||
})
|
|
||||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
||||||
}
|
|
||||||
|
|
||||||
// progressKeyboard builds the progress display with a dummy button and cancel.
|
// progressKeyboard builds the progress display with a dummy button and cancel.
|
||||||
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
|
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
|
||||||
progressText := fmt.Sprintf("%.1f%%", pct)
|
progressText := fmt.Sprintf("%.1f%%", pct)
|
||||||
if speed != "" {
|
if speed != "" {
|
||||||
progressText += fmt.Sprintf(" | %s/s", speed)
|
progressText += fmt.Sprintf(" | %s", speed)
|
||||||
}
|
}
|
||||||
if eta != "" {
|
if eta != "" {
|
||||||
progressText += fmt.Sprintf(" | ETA %s", eta)
|
progressText += fmt.Sprintf(" | ETA %s", eta)
|
||||||
@@ -102,85 +80,20 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// playlistNavKeyboard builds the paginated navigation for playlist selection.
|
// mediaTypeKeyboard builds the initial type picker (Video/Audio/Ask).
|
||||||
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup {
|
func mediaTypeKeyboard() models.InlineKeyboardMarkup {
|
||||||
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
|
return models.InlineKeyboardMarkup{
|
||||||
if totalPages < 1 {
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
totalPages = 1
|
{
|
||||||
}
|
{Text: "Video", CallbackData: "type_video"},
|
||||||
|
{Text: "Audio", CallbackData: "type_audio"},
|
||||||
// Build entry rows (2 per row)
|
{Text: "Ask", CallbackData: "type_ask"},
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
},
|
||||||
start := state.Page * state.PerPage
|
{
|
||||||
end := start + state.PerPage
|
|
||||||
if end > len(state.Entries) {
|
|
||||||
end = len(state.Entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := start; i < end; i += 2 {
|
|
||||||
row := []models.InlineKeyboardButton{}
|
|
||||||
// First entry
|
|
||||||
e := state.Entries[i]
|
|
||||||
prefix := " "
|
|
||||||
if state.Selected[e.ID] {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
// Truncate title for button
|
|
||||||
title := e.Title
|
|
||||||
if len(title) > 30 {
|
|
||||||
title = title[:27] + "..."
|
|
||||||
}
|
|
||||||
row = append(row, models.InlineKeyboardButton{
|
|
||||||
Text: prefix + title, CallbackData: "pl_entry_" + e.ID,
|
|
||||||
})
|
|
||||||
// Second entry if available
|
|
||||||
if i+1 < end {
|
|
||||||
e2 := state.Entries[i+1]
|
|
||||||
prefix2 := " "
|
|
||||||
if state.Selected[e2.ID] {
|
|
||||||
prefix2 = "> "
|
|
||||||
}
|
|
||||||
title2 := e2.Title
|
|
||||||
if len(title2) > 30 {
|
|
||||||
title2 = title2[:27] + "..."
|
|
||||||
}
|
|
||||||
row = append(row, models.InlineKeyboardButton{
|
|
||||||
Text: prefix2 + title2, CallbackData: "pl_entry_" + e2.ID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation row
|
|
||||||
navRow := []models.InlineKeyboardButton{}
|
|
||||||
navRow = append(navRow, models.InlineKeyboardButton{
|
|
||||||
Text: "<", CallbackData: fmt.Sprintf("pl_page_%d", state.Page-1),
|
|
||||||
})
|
|
||||||
navRow = append(navRow, models.InlineKeyboardButton{
|
|
||||||
Text: fmt.Sprintf("Page %d/%d", state.Page+1, totalPages), CallbackData: "noop",
|
|
||||||
})
|
|
||||||
navRow = append(navRow, models.InlineKeyboardButton{
|
|
||||||
Text: ">", CallbackData: fmt.Sprintf("pl_page_%d", state.Page+1),
|
|
||||||
})
|
|
||||||
rows = append(rows, navRow)
|
|
||||||
|
|
||||||
// Action row
|
|
||||||
actionRow := []models.InlineKeyboardButton{
|
|
||||||
{Text: "Select All", CallbackData: "pl_select_all"},
|
|
||||||
}
|
|
||||||
if selectedCount > 0 {
|
|
||||||
actionRow = append(actionRow, models.InlineKeyboardButton{
|
|
||||||
Text: fmt.Sprintf("Confirm (%d)", selectedCount), CallbackData: "pl_confirm",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rows = append(rows, actionRow)
|
|
||||||
|
|
||||||
// Cancel
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: "Cancel", CallbackData: "cancel_dl"},
|
{Text: "Cancel", CallbackData: "cancel_dl"},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteConfirmKeyboard builds the confirmation prompt for account deletion.
|
// deleteConfirmKeyboard builds the confirmation prompt for account deletion.
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"uptodownBot/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo) {
|
|
||||||
state := &domain.PlaylistState{
|
|
||||||
Entries: info.PlaylistEntries,
|
|
||||||
Page: 0,
|
|
||||||
PerPage: 10,
|
|
||||||
Selected: make(map[string]bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
ss := &stepState{
|
|
||||||
URL: url,
|
|
||||||
MediaInfo: info,
|
|
||||||
PlaylistState: state,
|
|
||||||
}
|
|
||||||
h.setUserStepState(chatID, ss)
|
|
||||||
|
|
||||||
h.sendMediaPreview(ctx, chatID, info)
|
|
||||||
|
|
||||||
kb := playlistNavKeyboard(state, 0)
|
|
||||||
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) getPlaylistState(chatID int64) *domain.PlaylistState {
|
|
||||||
ss := h.getUserStepState(chatID)
|
|
||||||
if ss == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ss.PlaylistState
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) handlePlaylistPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
|
|
||||||
state := h.getPlaylistState(chatID)
|
|
||||||
if state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page, err := strconv.Atoi(pageStr)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
|
|
||||||
if page < 0 || page >= totalPages {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state.Page = page
|
|
||||||
|
|
||||||
selectedCount := countSelected(state)
|
|
||||||
kb := playlistNavKeyboard(state, selectedCount)
|
|
||||||
ss := h.getUserStepState(chatID)
|
|
||||||
title := ""
|
|
||||||
if ss != nil {
|
|
||||||
title = ss.MediaInfo.Title
|
|
||||||
}
|
|
||||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) handlePlaylistEntry(ctx context.Context, chatID int64, msgID int, userID int64, entryID string) {
|
|
||||||
state := h.getPlaylistState(chatID)
|
|
||||||
if state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state.Selected[entryID] = !state.Selected[entryID]
|
|
||||||
|
|
||||||
selectedCount := countSelected(state)
|
|
||||||
kb := playlistNavKeyboard(state, selectedCount)
|
|
||||||
ss := h.getUserStepState(chatID)
|
|
||||||
title := ""
|
|
||||||
if ss != nil {
|
|
||||||
title = ss.MediaInfo.Title
|
|
||||||
}
|
|
||||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) handlePlaylistSelectAll(ctx context.Context, chatID int64, msgID int, userID int64) {
|
|
||||||
state := h.getPlaylistState(chatID)
|
|
||||||
if state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allSelected := true
|
|
||||||
for _, e := range state.Entries {
|
|
||||||
if !state.Selected[e.ID] {
|
|
||||||
allSelected = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range state.Entries {
|
|
||||||
state.Selected[e.ID] = !allSelected
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedCount := 0
|
|
||||||
if !allSelected {
|
|
||||||
selectedCount = len(state.Entries)
|
|
||||||
}
|
|
||||||
kb := playlistNavKeyboard(state, selectedCount)
|
|
||||||
ss := h.getUserStepState(chatID)
|
|
||||||
title := ""
|
|
||||||
if ss != nil {
|
|
||||||
title = ss.MediaInfo.Title
|
|
||||||
}
|
|
||||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
|
|
||||||
state := h.getPlaylistState(chatID)
|
|
||||||
if state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected []domain.PlaylistEntry
|
|
||||||
for _, e := range state.Entries {
|
|
||||||
if state.Selected[e.ID] {
|
|
||||||
selected = append(selected, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(selected) == 0 {
|
|
||||||
h.editText(ctx, chatID, msgID, "No videos selected.", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.clearStepState(chatID)
|
|
||||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos sequentially...", len(selected)), nil)
|
|
||||||
|
|
||||||
for i, entry := range selected {
|
|
||||||
h.sendText(ctx, chatID, fmt.Sprintf("Downloading (%d/%d): %s", i+1, len(selected), entry.Title))
|
|
||||||
|
|
||||||
info, err := h.YtDlp.GetMediaInfo(entry.URL)
|
|
||||||
if err != nil {
|
|
||||||
h.sendText(ctx, chatID, fmt.Sprintf("Failed: %s", err.Error()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
|
||||||
if prefErr != nil {
|
|
||||||
prefs = &domain.UserPreferences{
|
|
||||||
DefaultMediaType: domain.MediaTypeVideoAudio,
|
|
||||||
DefaultAudioFormat: "best",
|
|
||||||
DefaultVideoFormat: "best",
|
|
||||||
DefaultAudioContainer: "mp3",
|
|
||||||
DefaultVideoContainer: "mp4",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatID := prefs.DefaultVideoFormat
|
|
||||||
container := prefs.DefaultVideoContainer
|
|
||||||
if prefs.DefaultMediaType == domain.MediaTypeAudio {
|
|
||||||
formatID = prefs.DefaultAudioFormat
|
|
||||||
container = prefs.DefaultAudioContainer
|
|
||||||
}
|
|
||||||
format := &domain.Format{ID: formatID}
|
|
||||||
for _, f := range info.Formats {
|
|
||||||
if f.ID == formatID {
|
|
||||||
format = &f
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dlCtx, dlCancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
job := &domain.DownloadJob{
|
|
||||||
ID: h.generateJobID(),
|
|
||||||
UserID: userID,
|
|
||||||
ChatID: chatID,
|
|
||||||
URL: entry.URL,
|
|
||||||
MediaType: prefs.DefaultMediaType,
|
|
||||||
SelectedFormat: format,
|
|
||||||
Container: container,
|
|
||||||
Language: prefs.DefaultLanguage,
|
|
||||||
Status: domain.StatusDownloading,
|
|
||||||
Title: entry.Title,
|
|
||||||
FileSize: format.Filesize,
|
|
||||||
Done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
h.setActiveJob(userID, job)
|
|
||||||
h.setCancelFunc(userID, dlCancel)
|
|
||||||
|
|
||||||
h.runDownload(dlCtx, job)
|
|
||||||
|
|
||||||
// runDownload is synchronous — it blocks until the download completes.
|
|
||||||
// The Done channel is closed inside runDownload when it exits.
|
|
||||||
// No busy-polling needed.
|
|
||||||
}
|
|
||||||
|
|
||||||
h.sendText(ctx, chatID, "All playlist downloads completed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func countSelected(state *domain.PlaylistState) int {
|
|
||||||
count := 0
|
|
||||||
for _, s := range state.Selected {
|
|
||||||
if s {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ func (h *Handler) handleSearchQuery(ctx context.Context, chatID int64, text stri
|
|||||||
PerPage: 5,
|
PerPage: 5,
|
||||||
Source: source,
|
Source: source,
|
||||||
}
|
}
|
||||||
ss := &stepState{SearchState: state}
|
ss := &stepState{SearchState: state, CreatedAt: time.Now()}
|
||||||
h.setUserStepState(chatID, ss)
|
h.setUserStepState(chatID, ss)
|
||||||
|
|
||||||
kb := searchResultsKeyboard(state)
|
kb := searchResultsKeyboard(state)
|
||||||
|
|||||||
@@ -14,32 +14,32 @@ import (
|
|||||||
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||||
audioFmt := prefs.DefaultAudioFormat
|
audioFmt := prefs.DefaultAudioFormat
|
||||||
if audioFmt == "" {
|
if audioFmt == "" {
|
||||||
audioFmt = "best"
|
audioFmt = "ask"
|
||||||
}
|
}
|
||||||
videoFmt := prefs.DefaultVideoFormat
|
videoFmt := prefs.DefaultVideoFormat
|
||||||
if videoFmt == "" {
|
if videoFmt == "" {
|
||||||
videoFmt = "best"
|
videoFmt = "ask"
|
||||||
}
|
|
||||||
audioCont := prefs.DefaultAudioContainer
|
|
||||||
if audioCont == "" {
|
|
||||||
audioCont = "mp3"
|
|
||||||
}
|
|
||||||
videoCont := prefs.DefaultVideoContainer
|
|
||||||
if videoCont == "" {
|
|
||||||
videoCont = "mp4"
|
|
||||||
}
|
}
|
||||||
langLabel := prefs.DefaultLanguage
|
langLabel := prefs.DefaultLanguage
|
||||||
if langLabel == "" {
|
if langLabel == "" {
|
||||||
langLabel = "Default"
|
langLabel = "Default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaTypeLabel := "Ask"
|
||||||
|
switch prefs.DefaultMediaType {
|
||||||
|
case domain.MediaTypeAudio:
|
||||||
|
mediaTypeLabel = "Audio"
|
||||||
|
case domain.MediaTypeVideo:
|
||||||
|
mediaTypeLabel = "Video"
|
||||||
|
}
|
||||||
|
|
||||||
rows := [][]models.InlineKeyboardButton{
|
rows := [][]models.InlineKeyboardButton{
|
||||||
{
|
{
|
||||||
{Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"},
|
{Text: fmt.Sprintf("Media Type: %s", mediaTypeLabel), CallbackData: "settings_mediatype"},
|
||||||
|
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
{Text: fmt.Sprintf("Container: %s/%s", audioCont, videoCont), CallbackData: "settings_container"},
|
{Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"},
|
||||||
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
|
|
||||||
},
|
},
|
||||||
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
|
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
|
||||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||||
@@ -73,6 +73,9 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch setting {
|
switch setting {
|
||||||
|
case "mediatype":
|
||||||
|
text, kb := h.buildMediaTypePicker(prefs)
|
||||||
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
case "quality":
|
case "quality":
|
||||||
text, kb := h.buildQualityAudioVideoPicker(prefs)
|
text, kb := h.buildQualityAudioVideoPicker(prefs)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
@@ -82,15 +85,6 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
|
|||||||
case "quality_video":
|
case "quality_video":
|
||||||
text, kb := h.buildVideoQualityPicker(prefs)
|
text, kb := h.buildVideoQualityPicker(prefs)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
case "container":
|
|
||||||
text, kb := h.buildContainerAudioVideoPicker(prefs)
|
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
|
||||||
case "container_audio":
|
|
||||||
text, kb := h.buildAudioContainerPicker(prefs)
|
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
|
||||||
case "container_video":
|
|
||||||
text, kb := h.buildVideoContainerPicker(prefs)
|
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
|
||||||
case "language":
|
case "language":
|
||||||
text, kb := h.buildLanguagePicker(prefs)
|
text, kb := h.buildLanguagePicker(prefs)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
@@ -107,11 +101,13 @@ func (h *Handler) buildQualityAudioVideoPicker(prefs *domain.UserPreferences) (s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||||
bitrates := []string{"best", "128k", "192k", "256k", "320k"}
|
bitrates := []string{"ask", "best", "128k", "192k", "256k", "320k"}
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
rows := [][]models.InlineKeyboardButton{}
|
||||||
for _, b := range bitrates {
|
for _, b := range bitrates {
|
||||||
label := b
|
label := b
|
||||||
if b == prefs.DefaultAudioFormat {
|
if b == "ask" && prefs.DefaultAudioFormat == "" {
|
||||||
|
label = "> " + label
|
||||||
|
} else if b != "ask" && b == prefs.DefaultAudioFormat {
|
||||||
label = "> " + label
|
label = "> " + label
|
||||||
}
|
}
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
rows = append(rows, []models.InlineKeyboardButton{
|
||||||
@@ -125,12 +121,14 @@ func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||||
resolutions := []string{"best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
|
resolutions := []string{"ask", "best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
rows := [][]models.InlineKeyboardButton{}
|
||||||
row := []models.InlineKeyboardButton{}
|
row := []models.InlineKeyboardButton{}
|
||||||
for i, r := range resolutions {
|
for i, r := range resolutions {
|
||||||
label := r
|
label := r
|
||||||
if r == prefs.DefaultVideoFormat {
|
if r == "ask" && prefs.DefaultVideoFormat == "" {
|
||||||
|
label = "> " + label
|
||||||
|
} else if r != "ask" && r == prefs.DefaultVideoFormat {
|
||||||
label = "> " + label
|
label = "> " + label
|
||||||
}
|
}
|
||||||
row = append(row, models.InlineKeyboardButton{
|
row = append(row, models.InlineKeyboardButton{
|
||||||
@@ -147,56 +145,6 @@ func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string
|
|||||||
return "Select default video quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
return "Select default video quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildContainerAudioVideoPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
|
||||||
rows := [][]models.InlineKeyboardButton{
|
|
||||||
{{Text: "Audio", CallbackData: "settings_container_audio"}},
|
|
||||||
{{Text: "Video", CallbackData: "settings_container_video"}},
|
|
||||||
{{Text: "Back to Settings", CallbackData: "back_settings"}},
|
|
||||||
}
|
|
||||||
return "Choose container type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) buildAudioContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
|
||||||
containers := []string{"mp3", "m4a", "opus"}
|
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
|
||||||
for _, c := range containers {
|
|
||||||
label := c
|
|
||||||
if c == prefs.DefaultAudioContainer {
|
|
||||||
label = "> " + label
|
|
||||||
}
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: label, CallbackData: "settings_set_audiocontainer_" + c},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: "Back", CallbackData: "back_container"},
|
|
||||||
})
|
|
||||||
return "Select default audio container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) buildVideoContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
|
||||||
containers := []string{"mp4", "mkv", "webm"}
|
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
|
||||||
row := []models.InlineKeyboardButton{}
|
|
||||||
for i, c := range containers {
|
|
||||||
label := c
|
|
||||||
if c == prefs.DefaultVideoContainer {
|
|
||||||
label = "> " + label
|
|
||||||
}
|
|
||||||
row = append(row, models.InlineKeyboardButton{
|
|
||||||
Text: label, CallbackData: "settings_set_videocontainer_" + c,
|
|
||||||
})
|
|
||||||
if len(row) == 3 || i == len(containers)-1 {
|
|
||||||
rows = append(rows, row)
|
|
||||||
row = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: "Back", CallbackData: "back_container"},
|
|
||||||
})
|
|
||||||
return "Select default video container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||||
languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"}
|
languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"}
|
||||||
langLabels := map[string]string{
|
langLabels := map[string]string{
|
||||||
@@ -225,6 +173,31 @@ func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, mo
|
|||||||
return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||||
|
options := []struct {
|
||||||
|
mt domain.MediaType
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{domain.MediaTypeAsk, "Ask"},
|
||||||
|
{domain.MediaTypeVideo, "Video"},
|
||||||
|
{domain.MediaTypeAudio, "Audio"},
|
||||||
|
}
|
||||||
|
rows := [][]models.InlineKeyboardButton{}
|
||||||
|
for _, opt := range options {
|
||||||
|
label := opt.label
|
||||||
|
if opt.mt == prefs.DefaultMediaType {
|
||||||
|
label = "> " + label
|
||||||
|
}
|
||||||
|
rows = append(rows, []models.InlineKeyboardButton{
|
||||||
|
{Text: label, CallbackData: "settings_set_mediatype_" + opt.mt.String()},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows = append(rows, []models.InlineKeyboardButton{
|
||||||
|
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||||
|
})
|
||||||
|
return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
|
func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
|
||||||
parts := strings.SplitN(setting, "_", 2)
|
parts := strings.SplitN(setting, "_", 2)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
@@ -240,18 +213,28 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
|
|||||||
|
|
||||||
changed := false
|
changed := false
|
||||||
switch action {
|
switch action {
|
||||||
|
case "mediatype":
|
||||||
|
switch value {
|
||||||
|
case "ask":
|
||||||
|
prefs.DefaultMediaType = domain.MediaTypeAsk
|
||||||
|
case "audio":
|
||||||
|
prefs.DefaultMediaType = domain.MediaTypeAudio
|
||||||
|
case "video":
|
||||||
|
prefs.DefaultMediaType = domain.MediaTypeVideo
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
case "audioquality":
|
case "audioquality":
|
||||||
|
if value == "ask" {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
prefs.DefaultAudioFormat = value
|
prefs.DefaultAudioFormat = value
|
||||||
changed = true
|
changed = true
|
||||||
case "videoquality":
|
case "videoquality":
|
||||||
|
if value == "ask" {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
prefs.DefaultVideoFormat = value
|
prefs.DefaultVideoFormat = value
|
||||||
changed = true
|
changed = true
|
||||||
case "audiocontainer":
|
|
||||||
prefs.DefaultAudioContainer = value
|
|
||||||
changed = true
|
|
||||||
case "videocontainer":
|
|
||||||
prefs.DefaultVideoContainer = value
|
|
||||||
changed = true
|
|
||||||
case "language":
|
case "language":
|
||||||
prefs.DefaultLanguage = value
|
prefs.DefaultLanguage = value
|
||||||
changed = true
|
changed = true
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (int64, error) {
|
|||||||
|
|
||||||
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
|
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
|
||||||
if _, err := s.db.Exec(
|
if _, err := s.db.Exec(
|
||||||
"INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)",
|
"INSERT OR IGNORE INTO user_preferences (user_id, default_media_type, default_audio_format, default_video_format) VALUES (?, 'ask', '', '')",
|
||||||
userID,
|
userID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -90,14 +90,18 @@ func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
|
|||||||
p.DefaultMediaType = domain.MediaTypeAudio
|
p.DefaultMediaType = domain.MediaTypeAudio
|
||||||
case "video":
|
case "video":
|
||||||
p.DefaultMediaType = domain.MediaTypeVideo
|
p.DefaultMediaType = domain.MediaTypeVideo
|
||||||
|
case "ask":
|
||||||
|
p.DefaultMediaType = domain.MediaTypeAsk
|
||||||
default:
|
default:
|
||||||
p.DefaultMediaType = domain.MediaTypeVideoAudio
|
p.DefaultMediaType = domain.MediaTypeAsk
|
||||||
}
|
}
|
||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaTypeToString(mt domain.MediaType) string {
|
func mediaTypeToString(mt domain.MediaType) string {
|
||||||
switch mt {
|
switch mt {
|
||||||
|
case domain.MediaTypeAsk:
|
||||||
|
return "ask"
|
||||||
case domain.MediaTypeAudio:
|
case domain.MediaTypeAudio:
|
||||||
return "audio"
|
return "audio"
|
||||||
case domain.MediaTypeVideo:
|
case domain.MediaTypeVideo:
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ func TestPreferences(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetOrCreatePreferences() failed: %v", err)
|
t.Fatalf("GetOrCreatePreferences() failed: %v", err)
|
||||||
}
|
}
|
||||||
if prefs.DefaultMediaType != domain.MediaTypeVideoAudio {
|
if prefs.DefaultMediaType != domain.MediaTypeAsk {
|
||||||
t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType)
|
t.Errorf("default media type = %v, want Ask", prefs.DefaultMediaType)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.DefaultMediaType = domain.MediaTypeAudio
|
prefs.DefaultMediaType = domain.MediaTypeAudio
|
||||||
|
|||||||
Reference in New Issue
Block a user