feat: initial implementation of uptodownbot
Telegram bot for downloading media from any site using yt-dlp. Supports audio/video/both, quality selection, language picker, container format selection, playlist pagination, progress updates, per-user quotas, user preferences, download history, cancel at any step, polling and webhook modes, proxy support, and SQLite persistence.
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN
|
||||
HTTP_PROXY=
|
||||
HTTPS_PROXY=
|
||||
DB_PATH=data/uptodown.db
|
||||
DOWNLOAD_DIR=/tmp/uptodown
|
||||
MAX_FILE_SIZE_MB=2000
|
||||
COOKIES_FILE=
|
||||
TZ=UTC
|
||||
BOT_ALLOWED_USERS=
|
||||
BOT_WEBHOOK_URL=
|
||||
BOT_LISTEN=:8080
|
||||
BOT_TLS_CERT=
|
||||
BOT_TLS_KEY=
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.build/
|
||||
*.exe
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.DS_Store
|
||||
tmp/
|
||||
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM golang:1.26.4-alpine3.23 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ENV HTTP_PROXY=${HTTP_PROXY}
|
||||
ENV HTTPS_PROXY=${HTTPS_PROXY}
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build -o /usr/local/bin/uptodown-bot ./cmd/bot/
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates tzdata
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
COPY --from=builder /usr/local/bin/uptodown-bot /usr/local/bin/uptodown-bot
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["uptodown-bot"]
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
BINARY=uptodown-bot
|
||||
BUILD_DIR=.build
|
||||
|
||||
.PHONY: all build run test clean fmt tidy vet check docker docker-run
|
||||
|
||||
all: build test check
|
||||
|
||||
build:
|
||||
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/
|
||||
|
||||
run: build
|
||||
$(BUILD_DIR)/$(BINARY)
|
||||
|
||||
run-dev:
|
||||
go run ./cmd/bot/
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
check: fmt vet
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
docker:
|
||||
docker compose build
|
||||
|
||||
docker-run: docker
|
||||
docker compose up -d
|
||||
81
README.md
Normal file
81
README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# uptodownbot
|
||||
|
||||
Telegram bot for downloading media from supported sites using yt-dlp.
|
||||
|
||||
## Features
|
||||
|
||||
- Download audio, video, or combined audio+video from any site yt-dlp supports
|
||||
- Multi-step format selection: media type, quality, language, container
|
||||
- Playlist support with paginated entry selection (10 per page)
|
||||
- Download progress updates (every 5%)
|
||||
- Cancel downloads at any time
|
||||
- Per-user quota system (daily, weekly, monthly)
|
||||
- User preferences (default quality, container, language)
|
||||
- Download history
|
||||
- Configurable file size limit
|
||||
- Cookies support for age-restricted content
|
||||
- Proxy support via HTTP_PROXY/HTTPS_PROXY env vars
|
||||
- Webhook and polling modes
|
||||
- SQLite database for persistence
|
||||
|
||||
## Commands
|
||||
|
||||
- `/start` - Show main menu
|
||||
- `/settings` - Open settings
|
||||
- `/help` - Show help text
|
||||
- `/history` - View download history
|
||||
|
||||
Just send a URL to start downloading.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------- | ------------------ | -------------------------------- |
|
||||
| `BOT_TOKEN` | (required) | Telegram Bot API token |
|
||||
| `HTTP_PROXY` | empty | Outbound HTTP proxy |
|
||||
| `HTTPS_PROXY` | empty | Outbound HTTPS proxy |
|
||||
| `DB_PATH` | `data/uptodown.db` | SQLite database path |
|
||||
| `DOWNLOAD_DIR` | `/tmp/uptodown` | Temporary download directory |
|
||||
| `MAX_FILE_SIZE_MB` | `2000` | Maximum upload file size in MB |
|
||||
| `COOKIES_FILE` | empty | Path to cookies.txt for auth |
|
||||
| `TZ` | `UTC` | Timezone |
|
||||
| `BOT_ALLOWED_USERS` | empty | Comma-separated allowed user IDs |
|
||||
| `BOT_WEBHOOK_URL` | empty | Webhook URL (omit for polling) |
|
||||
| `BOT_LISTEN` | `:8080` | Webhook listen address |
|
||||
| `BOT_TLS_CERT` | empty | TLS certificate path |
|
||||
| `BOT_TLS_KEY` | empty | TLS key path |
|
||||
|
||||
## Running
|
||||
|
||||
### Polling mode (default)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env with your BOT_TOKEN
|
||||
make run-dev
|
||||
```
|
||||
|
||||
### Webhook mode
|
||||
|
||||
```bash
|
||||
export BOT_WEBHOOK_URL=https://your.domain.com/webhook
|
||||
make run-dev
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make build # build binary
|
||||
make test # run tests
|
||||
make fmt # format code
|
||||
make vet # vet code
|
||||
make check # fmt + vet
|
||||
```
|
||||
124
cmd/bot/main.go
Normal file
124
cmd/bot/main.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"uptodownBot/internal/dl"
|
||||
"uptodownBot/internal/handler"
|
||||
"uptodownBot/internal/repo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
godotenv.Load()
|
||||
|
||||
token := os.Getenv("BOT_TOKEN")
|
||||
dbPath := getEnvDefault("DB_PATH", "data/uptodown.db")
|
||||
downloadDir := getEnvDefault("DOWNLOAD_DIR", "/tmp/uptodown")
|
||||
maxFileSizeMB := getEnvIntDefault("MAX_FILE_SIZE_MB", 2000)
|
||||
cookiesFile := os.Getenv("COOKIES_FILE")
|
||||
|
||||
if token == "" {
|
||||
slog.Error("BOT_TOKEN is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
store, err := repo.NewStore(dbPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to open database", "path", dbPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ytdlp, err := dl.NewClient(cookiesFile)
|
||||
if err != nil {
|
||||
slog.Error("failed to init yt-dlp", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS"))
|
||||
|
||||
var h *handler.Handler
|
||||
|
||||
dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) {
|
||||
if update.Message != nil {
|
||||
h.HandleMessage(ctx, update.Message)
|
||||
}
|
||||
if update.CallbackQuery != nil {
|
||||
h.HandleCallback(ctx, update.CallbackQuery)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := tgbot.New(token, tgbot.WithDefaultHandler(dispatch))
|
||||
if err != nil {
|
||||
slog.Error("failed to create bot", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
h = handler.NewHandler(b, store, ytdlp, allowedUsers, downloadDir, int64(maxFileSizeMB)*1024*1024)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" {
|
||||
startWebhook(ctx, b, h, url)
|
||||
} else {
|
||||
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
||||
slog.Warn("failed to delete webhook", "error", err)
|
||||
}
|
||||
slog.Info("bot started",
|
||||
"allowed_users", len(allowedUsers),
|
||||
"mode", "polling",
|
||||
)
|
||||
b.Start(ctx)
|
||||
}
|
||||
|
||||
store.Close()
|
||||
}
|
||||
|
||||
func parseAllowedUsers(raw string) map[int64]bool {
|
||||
m := make(map[int64]bool)
|
||||
for _, s := range strings.Split(raw, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err == nil {
|
||||
m[id] = true
|
||||
} else {
|
||||
slog.Warn("invalid user ID in BOT_ALLOWED_USERS", "value", s)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getEnvDefault(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvIntDefault(key string, defaultVal int) int {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
n, err := strconv.Atoi(val)
|
||||
if err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
48
cmd/bot/webhook.go
Normal file
48
cmd/bot/webhook.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
|
||||
"uptodownBot/internal/handler"
|
||||
)
|
||||
|
||||
func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhookURL string) {
|
||||
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
||||
slog.Warn("failed to delete webhook", "error", err)
|
||||
}
|
||||
|
||||
if _, err := b.SetWebhook(ctx, &tgbot.SetWebhookParams{URL: webhookURL}); err != nil {
|
||||
slog.Error("set webhook", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
listenAddr := getEnvDefault("BOT_LISTEN", ":8080")
|
||||
tlsCert := os.Getenv("BOT_TLS_CERT")
|
||||
tlsKey := os.Getenv("BOT_TLS_KEY")
|
||||
|
||||
if tlsCert != "" && tlsKey != "" {
|
||||
slog.Info("starting HTTPS webhook", "addr", listenAddr)
|
||||
go func() {
|
||||
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, b.WebhookHandler()); err != nil {
|
||||
slog.Error("HTTPS server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(listenAddr, b.WebhookHandler()); err != nil {
|
||||
slog.Error("HTTP server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("webhook registered", "url", webhookURL)
|
||||
b.StartWebhook(ctx)
|
||||
}
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
uptodown-bot:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BOT_TOKEN=${BOT_TOKEN}
|
||||
- HTTP_PROXY=${HTTP_PROXY}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||
- DB_PATH=${DB_PATH}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- uptodown_data:/data
|
||||
|
||||
volumes:
|
||||
uptodown_data:
|
||||
driver: local
|
||||
26
go.mod
Normal file
26
go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module uptodownBot
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/go-telegram/bot v1.21.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
70
go.sum
Normal file
70
go.sum
Normal file
@@ -0,0 +1,70 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
|
||||
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
78
internal/dl/progress.go
Normal file
78
internal/dl/progress.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package dl
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
var progressRE = regexp.MustCompile(`\[\w+\]\s+([\d.]+)%\s+of\s+~?([\d.]+)(\w+)`)
|
||||
|
||||
type progressUpdate struct {
|
||||
pct float64
|
||||
speed string
|
||||
eta string
|
||||
}
|
||||
|
||||
// parseProgressLine parses a single yt-dlp stderr progress line.
|
||||
// Returns nil if the line doesn't contain progress info.
|
||||
func parseProgressLine(line string) *progressUpdate {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "[download]") {
|
||||
return nil
|
||||
}
|
||||
// Try to extract the simple percentage from the default format:
|
||||
// [download] 45.2% of 123.45MiB at 2.34MiB/s ETA 00:45
|
||||
m := progressRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
pct, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
u := &progressUpdate{pct: pct}
|
||||
|
||||
// Extract speed and ETA from remaining parts
|
||||
rest := line
|
||||
if idx := strings.Index(rest, "%"); idx >= 0 {
|
||||
rest = rest[idx+1:]
|
||||
}
|
||||
// Look for "at <speed>"
|
||||
if atIdx := strings.Index(rest, " at "); atIdx >= 0 {
|
||||
afterAt := rest[atIdx+4:]
|
||||
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 {
|
||||
u.speed = strings.TrimSpace(afterAt[:spaceIdx])
|
||||
rest = afterAt[spaceIdx:]
|
||||
}
|
||||
}
|
||||
// Look for "ETA <duration>"
|
||||
if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 {
|
||||
etaStr := strings.TrimSpace(rest[etaIdx+4:])
|
||||
u.eta = etaStr
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// readProgress reads yt-dlp stderr and sends progress updates to the channel.
|
||||
func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
|
||||
defer close(updates)
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parsed := parseProgressLine(line)
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
job.Progress = parsed.pct
|
||||
job.Speed = parsed.speed
|
||||
job.ETA = parsed.eta
|
||||
updates <- job
|
||||
}
|
||||
}
|
||||
72
internal/dl/progress_test.go
Normal file
72
internal/dl/progress_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package dl
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseProgressLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
pct float64
|
||||
speed string
|
||||
eta string
|
||||
hasRes bool
|
||||
}{
|
||||
{
|
||||
line: "[download] 45.2% of 123.45MiB at 2.34MiB/s ETA 00:45",
|
||||
pct: 45.2,
|
||||
speed: "2.34MiB/s",
|
||||
eta: "00:45",
|
||||
hasRes: true,
|
||||
},
|
||||
{
|
||||
line: "[download] 10.0% of ~500.00MiB at 1.50MiB/s ETA 05:30",
|
||||
pct: 10.0,
|
||||
speed: "1.50MiB/s",
|
||||
eta: "05:30",
|
||||
hasRes: true,
|
||||
},
|
||||
{
|
||||
line: "[download] 100.0% of 50.00MiB at 5.00MiB/s ETA 00:00",
|
||||
pct: 100.0,
|
||||
speed: "5.00MiB/s",
|
||||
eta: "00:00",
|
||||
hasRes: true,
|
||||
},
|
||||
{
|
||||
line: "[youtube] Extracting URL: https://example.com",
|
||||
hasRes: false,
|
||||
},
|
||||
{
|
||||
line: "",
|
||||
hasRes: false,
|
||||
},
|
||||
{
|
||||
line: "some random log line",
|
||||
hasRes: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseProgressLine(tt.line)
|
||||
if !tt.hasRes {
|
||||
if got != nil {
|
||||
t.Errorf("parseProgressLine(%q) = %+v, want nil", tt.line, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == nil {
|
||||
t.Errorf("parseProgressLine(%q) = nil, want result", tt.line)
|
||||
continue
|
||||
}
|
||||
if math.Abs(got.pct-tt.pct) > 0.01 {
|
||||
t.Errorf("parseProgressLine(%q).pct = %f, want %f", tt.line, got.pct, tt.pct)
|
||||
}
|
||||
if got.speed != tt.speed {
|
||||
t.Errorf("parseProgressLine(%q).speed = %q, want %q", tt.line, got.speed, tt.speed)
|
||||
}
|
||||
if got.eta != tt.eta {
|
||||
t.Errorf("parseProgressLine(%q).eta = %q, want %q", tt.line, got.eta, tt.eta)
|
||||
}
|
||||
}
|
||||
}
|
||||
411
internal/dl/ytdlp.go
Normal file
411
internal/dl/ytdlp.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package dl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
// Client wraps calls to the yt-dlp executable.
|
||||
type Client struct {
|
||||
binaryPath string
|
||||
cookiesFile string
|
||||
}
|
||||
|
||||
// NewClient creates a new yt-dlp client. It checks for the binary.
|
||||
func NewClient(cookiesFile string) (*Client, error) {
|
||||
path, err := exec.LookPath("yt-dlp")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
|
||||
}
|
||||
slog.Info("yt-dlp found", "path", path)
|
||||
return &Client{binaryPath: path, cookiesFile: cookiesFile}, nil
|
||||
}
|
||||
|
||||
type ytdlpFormat struct {
|
||||
FormatID string `json:"format_id"`
|
||||
Ext string `json:"ext"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
FilesizeApprox int64 `json:"filesize_approx"`
|
||||
Codec string `json:"vcodec"`
|
||||
ACodec string `json:"acodec"`
|
||||
TBR float64 `json:"tbr"`
|
||||
ABR float64 `json:"abr"`
|
||||
Language string `json:"language"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
type ytdlpInfo struct {
|
||||
Title string `json:"title"`
|
||||
Duration float64 `json:"duration"`
|
||||
IsLive bool `json:"is_live"`
|
||||
Formats []ytdlpFormat `json:"formats"`
|
||||
Playlist string `json:"playlist"`
|
||||
PlaylistID string `json:"playlist_id"`
|
||||
PlaylistCount int `json:"playlist_count"`
|
||||
Entries []json.RawMessage `json:"entries"`
|
||||
RequestedFormats []ytdlpFormat `json:"requested_formats"`
|
||||
}
|
||||
|
||||
type ytdlpPlaylistEntry struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
// GetMediaInfo fetches format information for a given URL.
|
||||
func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
||||
args := []string{
|
||||
"-J", "--no-download",
|
||||
"--no-warnings",
|
||||
url,
|
||||
}
|
||||
if c.cookiesFile != "" {
|
||||
args = append(args, "--cookies", c.cookiesFile)
|
||||
}
|
||||
|
||||
slog.Info("fetching media info", "url", url)
|
||||
out, err := c.run(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp info failed: %w", err)
|
||||
}
|
||||
|
||||
var info ytdlpInfo
|
||||
if err := json.Unmarshal(out, &info); err != nil {
|
||||
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
|
||||
}
|
||||
|
||||
if info.IsLive {
|
||||
return nil, fmt.Errorf("cannot download live streams")
|
||||
}
|
||||
|
||||
mi := &domain.MediaInfo{
|
||||
URL: url,
|
||||
Title: info.Title,
|
||||
Duration: info.Duration,
|
||||
IsPlaylist: info.Playlist != "",
|
||||
}
|
||||
|
||||
if mi.IsPlaylist {
|
||||
mi.PlaylistCount = info.PlaylistCount
|
||||
mi.PlaylistEntries = c.parsePlaylistEntries(info.Entries)
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
langSet := make(map[string]bool)
|
||||
for _, f := range info.Formats {
|
||||
ff := c.convertFormat(f)
|
||||
mi.Formats = append(mi.Formats, ff)
|
||||
if ff.Language != "" && !langSet[ff.Language] {
|
||||
langSet[ff.Language] = true
|
||||
mi.Languages = append(mi.Languages, ff.Language)
|
||||
}
|
||||
if ff.Filesize > 0 {
|
||||
mi.EstimatedSize += ff.Filesize
|
||||
}
|
||||
}
|
||||
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
// GetPlaylistInfo fetches a flat playlist listing (fast, no format detail).
|
||||
func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
|
||||
args := []string{
|
||||
"-J", "--flat-playlist", "--no-download",
|
||||
"--no-warnings",
|
||||
url,
|
||||
}
|
||||
if c.cookiesFile != "" {
|
||||
args = append(args, "--cookies", c.cookiesFile)
|
||||
}
|
||||
|
||||
slog.Info("fetching playlist info", "url", url)
|
||||
out, err := c.run(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp playlist info failed: %w", err)
|
||||
}
|
||||
|
||||
var info ytdlpInfo
|
||||
if err := json.Unmarshal(out, &info); err != nil {
|
||||
return nil, fmt.Errorf("parse yt-dlp playlist output: %w", err)
|
||||
}
|
||||
|
||||
mi := &domain.MediaInfo{
|
||||
URL: url,
|
||||
Title: info.Title,
|
||||
IsPlaylist: true,
|
||||
PlaylistCount: info.PlaylistCount,
|
||||
PlaylistEntries: c.parsePlaylistEntries(info.Entries),
|
||||
}
|
||||
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry {
|
||||
var result []domain.PlaylistEntry
|
||||
for _, raw := range entries {
|
||||
var entry ytdlpPlaylistEntry
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
slog.Warn("failed to parse playlist entry", "error", err)
|
||||
continue
|
||||
}
|
||||
result = append(result, domain.PlaylistEntry{
|
||||
ID: entry.ID,
|
||||
Title: entry.Title,
|
||||
URL: entry.URL,
|
||||
Duration: entry.Duration,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Client) convertFormat(f ytdlpFormat) domain.Format {
|
||||
ff := domain.Format{
|
||||
ID: f.FormatID,
|
||||
Extension: f.Ext,
|
||||
Width: f.Width,
|
||||
Height: f.Height,
|
||||
Resolution: f.Resolution,
|
||||
Codec: f.Codec,
|
||||
Language: f.Language,
|
||||
}
|
||||
if f.Filesize > 0 {
|
||||
ff.Filesize = f.Filesize
|
||||
} else {
|
||||
ff.Filesize = f.FilesizeApprox
|
||||
}
|
||||
if f.TBR > 0 {
|
||||
ff.Bitrate = fmt.Sprintf("%.0fk", f.TBR)
|
||||
} else if f.ABR > 0 {
|
||||
ff.Bitrate = fmt.Sprintf("%.0fk", f.ABR)
|
||||
}
|
||||
ff.HasVideo = f.Codec != "none" && f.Codec != ""
|
||||
ff.HasAudio = f.ACodec != "none" && f.ACodec != ""
|
||||
ff.IsAudioOnly = !ff.HasVideo && ff.HasAudio
|
||||
ff.IsVideoOnly = ff.HasVideo && !ff.HasAudio
|
||||
return ff
|
||||
}
|
||||
|
||||
// StartDownload begins a download and sends progress updates to the channel.
|
||||
// Returns the job and an updates channel. Caller must consume from updates until closed.
|
||||
func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-chan *domain.DownloadJob, error) {
|
||||
if err := os.MkdirAll(downloadDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create download dir: %w", err)
|
||||
}
|
||||
|
||||
formatID := job.SelectedFormat.ID
|
||||
var args []string
|
||||
|
||||
// Build format string for merge
|
||||
switch job.MediaType {
|
||||
case domain.MediaTypeAudio:
|
||||
args = append(args, "-f", formatID, "-x")
|
||||
if job.Container != "" {
|
||||
args = append(args, "--audio-format", job.Container)
|
||||
}
|
||||
case domain.MediaTypeVideo:
|
||||
args = append(args, "-f", formatID)
|
||||
case domain.MediaTypeVideoAudio:
|
||||
// Download best video + best audio that match
|
||||
args = append(args, "-f", formatID+"+bestaudio/best")
|
||||
if job.Container != "" {
|
||||
args = append(args, "--merge-output-format", job.Container)
|
||||
}
|
||||
}
|
||||
|
||||
// Output template
|
||||
outputPath := filepath.Join(downloadDir, "%(id)s.%(ext)s")
|
||||
args = append(args, "--output", outputPath)
|
||||
|
||||
// Progress reporting
|
||||
args = append(args, "--newline", "--no-progress")
|
||||
|
||||
// Language selection if specified
|
||||
if job.Language != "" {
|
||||
args = append(args, "--sub-langs", job.Language)
|
||||
args = append(args, "--audio-multi-streams")
|
||||
}
|
||||
|
||||
// Cookies
|
||||
if c.cookiesFile != "" {
|
||||
args = append(args, "--cookies", c.cookiesFile)
|
||||
}
|
||||
|
||||
args = append(args, job.URL)
|
||||
|
||||
slog.Info("starting download", "url", job.URL, "format", formatID)
|
||||
|
||||
cmd := exec.Command(c.binaryPath, args...)
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start yt-dlp: %w", err)
|
||||
}
|
||||
|
||||
job.Cmd = cmd
|
||||
job.CancelCh = make(chan struct{})
|
||||
|
||||
updates := make(chan *domain.DownloadJob, 100)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if job.Status != domain.StatusCancelled {
|
||||
job.Status = domain.StatusCompleted
|
||||
updates <- job
|
||||
}
|
||||
close(updates)
|
||||
}()
|
||||
|
||||
progressDone := make(chan struct{})
|
||||
go func() {
|
||||
readProgress(stderr, updates, job)
|
||||
close(progressDone)
|
||||
}()
|
||||
|
||||
// Wait for either completion or cancellation
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-job.CancelCh:
|
||||
slog.Info("cancelling download", "url", job.URL)
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
cmd.Process.Kill()
|
||||
}
|
||||
<-progressDone
|
||||
job.Status = domain.StatusCancelled
|
||||
case err := <-done:
|
||||
<-progressDone
|
||||
if err != nil {
|
||||
job.Status = domain.StatusFailed
|
||||
slog.Error("download failed", "url", job.URL, "error", err)
|
||||
} else {
|
||||
job.Status = domain.StatusCompleted
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// CancelDownload sends a cancellation signal to a running download.
|
||||
func CancelDownload(job *domain.DownloadJob) {
|
||||
if job == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case job.CancelCh <- struct{}{}:
|
||||
default:
|
||||
// Already cancelled or not started
|
||||
}
|
||||
if job.Cmd != nil && job.Cmd.Process != nil {
|
||||
job.Cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) run(args []string) ([]byte, error) {
|
||||
cmd := exec.Command(c.binaryPath, args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
stderrStr := strings.TrimSpace(stderr.String())
|
||||
slog.Warn("yt-dlp run failed", "stderr", stderrStr, "error", err)
|
||||
// Extract user-friendly error message
|
||||
if stderrStr != "" {
|
||||
return nil, errors.New(stderrStr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FilterFormatsByType filters formats by the requested media type.
|
||||
func FilterFormatsByType(formats []domain.Format, mt domain.MediaType) []domain.Format {
|
||||
var filtered []domain.Format
|
||||
for _, f := range formats {
|
||||
switch mt {
|
||||
case domain.MediaTypeAudio:
|
||||
if f.IsAudioOnly {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
case domain.MediaTypeVideo:
|
||||
if f.IsVideoOnly {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
case domain.MediaTypeVideoAudio:
|
||||
if f.HasVideo && f.HasAudio {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// DeduplicateResolutions reduces formats to unique resolution labels.
|
||||
func DeduplicateResolutions(formats []domain.Format) []domain.Format {
|
||||
seen := make(map[string]bool)
|
||||
var result []domain.Format
|
||||
for _, f := range formats {
|
||||
label := formatLabel(f)
|
||||
if !seen[label] {
|
||||
seen[label] = true
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatLabel returns a human-readable label for a format.
|
||||
func formatLabel(f domain.Format) string {
|
||||
if f.IsAudioOnly {
|
||||
if f.Bitrate != "" {
|
||||
return f.Bitrate
|
||||
}
|
||||
return "audio"
|
||||
}
|
||||
if f.Resolution != "" {
|
||||
return f.Resolution
|
||||
}
|
||||
if f.Height > 0 {
|
||||
return fmt.Sprintf("%dp", f.Height)
|
||||
}
|
||||
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"}
|
||||
}
|
||||
}
|
||||
103
internal/dl/ytdlp_test.go
Normal file
103
internal/dl/ytdlp_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package dl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
func TestFilterFormatsByType(t *testing.T) {
|
||||
formats := []domain.Format{
|
||||
{ID: "1", HasVideo: true, HasAudio: false, IsVideoOnly: true, IsAudioOnly: false},
|
||||
{ID: "2", HasVideo: false, HasAudio: true, IsVideoOnly: false, IsAudioOnly: true},
|
||||
{ID: "3", HasVideo: true, HasAudio: true, IsVideoOnly: false, IsAudioOnly: false},
|
||||
{ID: "4", HasVideo: true, HasAudio: false, IsVideoOnly: true, IsAudioOnly: false},
|
||||
{ID: "5", HasVideo: false, HasAudio: true, IsVideoOnly: false, IsAudioOnly: true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
mediaType domain.MediaType
|
||||
wantIDs []string
|
||||
}{
|
||||
{domain.MediaTypeAudio, []string{"2", "5"}},
|
||||
{domain.MediaTypeVideo, []string{"1", "4"}},
|
||||
{domain.MediaTypeVideoAudio, []string{"3"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FilterFormatsByType(formats, tt.mediaType)
|
||||
if len(got) != len(tt.wantIDs) {
|
||||
t.Errorf("FilterFormatsByType(%v) = %d results, want %d", tt.mediaType, len(got), len(tt.wantIDs))
|
||||
continue
|
||||
}
|
||||
for i, f := range got {
|
||||
if f.ID != tt.wantIDs[i] {
|
||||
t.Errorf("FilterFormatsByType(%v)[%d].ID = %s, want %s", tt.mediaType, i, f.ID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduplicateResolutions(t *testing.T) {
|
||||
formats := []domain.Format{
|
||||
{ID: "1", Resolution: "1920x1080", Height: 1080, IsVideoOnly: true},
|
||||
{ID: "2", Resolution: "1920x1080", Height: 1080, IsVideoOnly: true},
|
||||
{ID: "3", Resolution: "1280x720", Height: 720, IsVideoOnly: true},
|
||||
{ID: "4", Bitrate: "128k", IsAudioOnly: true},
|
||||
{ID: "5", Bitrate: "128k", IsAudioOnly: true},
|
||||
}
|
||||
got := DeduplicateResolutions(formats)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("DeduplicateResolutions = %d results, want 3", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
mt domain.MediaType
|
||||
want []string
|
||||
}{
|
||||
{domain.MediaTypeAudio, []string{"mp3", "m4a", "opus"}},
|
||||
{domain.MediaTypeVideo, []string{"mp4", "mkv", "webm"}},
|
||||
{domain.MediaTypeVideoAudio, []string{"mp4", "mkv"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ContainerOptions(tt.mt)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("ContainerOptions(%v) = %v, want %v", tt.mt, got, tt.want)
|
||||
continue
|
||||
}
|
||||
for i, c := range got {
|
||||
if c != tt.want[i] {
|
||||
t.Errorf("ContainerOptions(%v)[%d] = %s, want %s", tt.mt, i, c, tt.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerOptionsZeroValue(t *testing.T) {
|
||||
got := ContainerOptions(domain.MediaType(0))
|
||||
want := []string{"mp4"}
|
||||
if len(got) != 1 || got[0] != want[0] {
|
||||
t.Errorf("ContainerOptions(0) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
f domain.Format
|
||||
want string
|
||||
}{
|
||||
{domain.Format{IsAudioOnly: true, Bitrate: "128k"}, "128k"},
|
||||
{domain.Format{IsAudioOnly: true}, "audio"},
|
||||
{domain.Format{Resolution: "1920x1080", Height: 1080}, "1920x1080"},
|
||||
{domain.Format{Height: 720}, "720p"},
|
||||
{domain.Format{ID: "137"}, "137"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := formatLabel(tt.f)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatLabel(%+v) = %q, want %q", tt.f, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
internal/domain/constants.go
Normal file
22
internal/domain/constants.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RateLimitInterval = 1 * time.Second
|
||||
ProgressThreshold = 5.0
|
||||
MaxFileSizeEnvKey = "MAX_FILE_SIZE_MB"
|
||||
DefaultMaxFileSize = 2000
|
||||
DefaultDailyQuotaMB = 100
|
||||
DefaultWeeklyQuotaMB = 500
|
||||
DefaultMonthlyQuotaMB = 2000
|
||||
CleanupInterval = 10 * time.Second
|
||||
FileRetention = 1 * time.Hour
|
||||
DownloadDirEnvKey = "DOWNLOAD_DIR"
|
||||
DefaultDownloadDir = "/tmp/uptodown"
|
||||
CookiesFileEnvKey = "COOKIES_FILE"
|
||||
DBPathEnvKey = "DB_PATH"
|
||||
DefaultDBPath = "data/uptodown.db"
|
||||
DateLayout = "2006-01-02"
|
||||
QuotaProgressThreshold = 50.0
|
||||
)
|
||||
19
internal/domain/sanitize.go
Normal file
19
internal/domain/sanitize.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateURL checks if the given string is a valid HTTP(S) URL.
|
||||
func ValidateURL(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
26
internal/domain/sanitize_test.go
Normal file
26
internal/domain/sanitize_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"https://www.youtube.com/watch?v=123", "https://www.youtube.com/watch?v=123", true},
|
||||
{"http://example.com/video", "http://example.com/video", true},
|
||||
{"https://vimeo.com/123456", "https://vimeo.com/123456", true},
|
||||
{"https://www.pornhub.com/view_video.php?viewkey=123", "https://www.pornhub.com/view_video.php?viewkey=123", true},
|
||||
{"", "", false},
|
||||
{"not a url", "", false},
|
||||
{"ftp://example.com", "", false},
|
||||
{" https://example.com ", "https://example.com", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := ValidateURL(tt.input)
|
||||
if ok != tt.ok || (ok && got != tt.want) {
|
||||
t.Errorf("ValidateURL(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
163
internal/domain/types.go
Normal file
163
internal/domain/types.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package domain
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// MediaType represents the type of media the user wants to download.
|
||||
type MediaType int
|
||||
|
||||
const (
|
||||
MediaTypeAudio MediaType = iota + 1
|
||||
MediaTypeVideo
|
||||
MediaTypeVideoAudio
|
||||
)
|
||||
|
||||
func (m MediaType) String() string {
|
||||
switch m {
|
||||
case MediaTypeAudio:
|
||||
return "audio"
|
||||
case MediaTypeVideo:
|
||||
return "video"
|
||||
case MediaTypeVideoAudio:
|
||||
return "video_audio"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Format represents a downloadable format from yt-dlp.
|
||||
type Format struct {
|
||||
ID string
|
||||
Extension string
|
||||
Width int
|
||||
Height int
|
||||
Resolution string
|
||||
Filesize int64
|
||||
Codec string
|
||||
Bitrate string
|
||||
Language string
|
||||
IsAudioOnly bool
|
||||
IsVideoOnly bool
|
||||
HasAudio bool
|
||||
HasVideo bool
|
||||
}
|
||||
|
||||
// MediaInfo holds the full information about a downloadable media item.
|
||||
type MediaInfo struct {
|
||||
URL string
|
||||
Title string
|
||||
Duration float64
|
||||
Formats []Format
|
||||
Languages []string
|
||||
IsPlaylist bool
|
||||
PlaylistCount int
|
||||
PlaylistEntries []PlaylistEntry
|
||||
EstimatedSize int64
|
||||
}
|
||||
|
||||
// PlaylistEntry represents a single entry in a playlist.
|
||||
type PlaylistEntry struct {
|
||||
ID string
|
||||
Title string
|
||||
URL string
|
||||
Duration float64
|
||||
}
|
||||
|
||||
// DownloadStatus represents the current state of a download job.
|
||||
type DownloadStatus int
|
||||
|
||||
const (
|
||||
StatusPending DownloadStatus = iota
|
||||
StatusFetching
|
||||
StatusDownloading
|
||||
StatusProcessing
|
||||
StatusCompleted
|
||||
StatusFailed
|
||||
StatusCancelled
|
||||
)
|
||||
|
||||
func (s DownloadStatus) String() string {
|
||||
switch s {
|
||||
case StatusPending:
|
||||
return "Pending"
|
||||
case StatusFetching:
|
||||
return "Fetching info"
|
||||
case StatusDownloading:
|
||||
return "Downloading"
|
||||
case StatusProcessing:
|
||||
return "Processing"
|
||||
case StatusCompleted:
|
||||
return "Completed"
|
||||
case StatusFailed:
|
||||
return "Failed"
|
||||
case StatusCancelled:
|
||||
return "Cancelled"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadJob represents an active download process for a user.
|
||||
type DownloadJob struct {
|
||||
ID string
|
||||
UserID int64
|
||||
ChatID int64
|
||||
MessageID int
|
||||
URL string
|
||||
MediaType MediaType
|
||||
SelectedFormat *Format
|
||||
Container string
|
||||
Language string
|
||||
Status DownloadStatus
|
||||
Progress float64
|
||||
Speed string
|
||||
ETA string
|
||||
FileSize int64
|
||||
FilePath string
|
||||
Title string
|
||||
QuotaApplied bool
|
||||
CancelCh chan struct{}
|
||||
Cmd *exec.Cmd
|
||||
}
|
||||
|
||||
// UserPreferences holds per-user default preferences.
|
||||
type UserPreferences struct {
|
||||
DefaultMediaType MediaType
|
||||
DefaultQuality string
|
||||
DefaultContainer string
|
||||
DefaultLanguage string
|
||||
}
|
||||
|
||||
// UserQuotas holds the quota usage and limits for a user.
|
||||
type UserQuotas struct {
|
||||
DailyLimitMB int64
|
||||
DailyUsedMB int64
|
||||
DailyDate string
|
||||
WeeklyLimitMB int64
|
||||
WeeklyUsedMB int64
|
||||
WeekStart string
|
||||
MonthlyLimitMB int64
|
||||
MonthlyUsedMB int64
|
||||
MonthStart string
|
||||
}
|
||||
|
||||
// DownloadRecord is a completed download entry stored in history.
|
||||
type DownloadRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
URL string
|
||||
Title string
|
||||
MediaType string
|
||||
FormatID string
|
||||
Container string
|
||||
FileSize int64
|
||||
Status 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
|
||||
}
|
||||
458
internal/handler/download.go
Normal file
458
internal/handler/download.go
Normal file
@@ -0,0 +1,458 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"uptodownBot/internal/dl"
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
// Step state stored per user during the download flow
|
||||
type stepState struct {
|
||||
URL string
|
||||
MediaInfo *domain.MediaInfo
|
||||
Step int // 0=type, 1=quality, 2=language, 3=container
|
||||
MediaType domain.MediaType
|
||||
Quality string
|
||||
Language string
|
||||
Container string
|
||||
FormatIDs []string
|
||||
PlaylistState *domain.PlaylistState
|
||||
URLMessageID int // original URL message ID for editing
|
||||
}
|
||||
|
||||
// getUserStepState retrieves the current step state for a user.
|
||||
func (h *Handler) getUserStepState(chatID int64) *stepState {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.stepStates[chatID]
|
||||
}
|
||||
|
||||
func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.stepStates[chatID] = ss
|
||||
}
|
||||
|
||||
func (h *Handler) clearStepState(chatID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.stepStates, chatID)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
url, ok := domain.ValidateURL(text)
|
||||
if !ok {
|
||||
h.sendText(ctx, chatID, "Please send a valid URL (http/https).")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for active job
|
||||
if job := h.getActiveJob(userID); job != nil {
|
||||
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check quota
|
||||
info, err := h.YtDlp.GetMediaInfo(url)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL") {
|
||||
h.sendText(ctx, chatID, "This URL is not supported.")
|
||||
} else if strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies") {
|
||||
h.sendText(ctx, chatID, "This content requires authentication (cookies).")
|
||||
} else if strings.Contains(errMsg, "live") {
|
||||
h.sendText(ctx, chatID, "Cannot download live streams.")
|
||||
} else {
|
||||
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if info.IsPlaylist {
|
||||
h.handlePlaylist(ctx, chatID, userID, url, info)
|
||||
return
|
||||
}
|
||||
|
||||
if len(info.Formats) == 0 {
|
||||
h.sendText(ctx, chatID, "No formats available for this URL.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check quota before starting flow
|
||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
||||
if err == nil {
|
||||
q = h.resetQuotasIfNeeded(q)
|
||||
estimatedMB := info.EstimatedSize / (1024 * 1024)
|
||||
if estimatedMB > 0 {
|
||||
if q.DailyUsedMB+estimatedMB > q.DailyLimitMB ||
|
||||
q.WeeklyUsedMB+estimatedMB > q.WeeklyLimitMB ||
|
||||
q.MonthlyUsedMB+estimatedMB > q.MonthlyLimitMB {
|
||||
h.sendText(ctx, chatID, fmt.Sprintf(
|
||||
"Quota exceeded. Daily: %d/%d MB, Weekly: %d/%d MB, Monthly: %d/%d MB.",
|
||||
q.DailyUsedMB, q.DailyLimitMB, q.WeeklyUsedMB, q.WeeklyLimitMB, q.MonthlyUsedMB, q.MonthlyLimitMB))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ss := &stepState{
|
||||
URL: url,
|
||||
MediaInfo: info,
|
||||
Step: 0,
|
||||
}
|
||||
h.setUserStepState(chatID, ss)
|
||||
h.editText(ctx, chatID, 0, fmt.Sprintf("URL: %s\nTitle: %s\n\nSelect media type:", url, info.Title), &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTypeSelection processes the media type choice.
|
||||
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, choice string) {
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
switch choice {
|
||||
case "audio":
|
||||
ss.MediaType = domain.MediaTypeAudio
|
||||
case "video":
|
||||
ss.MediaType = domain.MediaTypeVideo
|
||||
case "both":
|
||||
ss.MediaType = domain.MediaTypeVideoAudio
|
||||
default:
|
||||
return
|
||||
}
|
||||
ss.Step = 1
|
||||
|
||||
// Filter and deduplicate formats by type
|
||||
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, ss.MediaType)
|
||||
if len(filtered) == 0 {
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("No %s formats available for this URL.", choice), &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back", CallbackData: "back_step"}, {Text: "Cancel", CallbackData: "cancel_dl"}},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
deduped := dl.DeduplicateResolutions(filtered)
|
||||
ss.FormatIDs = make([]string, len(deduped))
|
||||
labels := make([]string, len(deduped))
|
||||
for i, f := range deduped {
|
||||
ss.FormatIDs[i] = f.ID
|
||||
labels[i] = formatLabelForDisplay(f)
|
||||
}
|
||||
// Add "Best" option first
|
||||
ss.FormatIDs = append([]string{"best"}, ss.FormatIDs...)
|
||||
labels = append([]string{"Best"}, labels...)
|
||||
|
||||
h.setUserStepState(chatID, ss)
|
||||
kb := formatKeyboard("quality_", labels)
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Title: %s\n\nSelect quality:", ss.MediaInfo.Title), &kb)
|
||||
}
|
||||
|
||||
func formatLabelForDisplay(f domain.Format) string {
|
||||
if f.Resolution != "" {
|
||||
label := f.Resolution
|
||||
if f.Filesize > 0 {
|
||||
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
|
||||
}
|
||||
return label
|
||||
}
|
||||
if f.Bitrate != "" {
|
||||
label := f.Bitrate
|
||||
if f.Filesize > 0 {
|
||||
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
|
||||
}
|
||||
return label
|
||||
}
|
||||
return f.ID
|
||||
}
|
||||
|
||||
// handleQualitySelection processes the quality choice.
|
||||
func (h *Handler) handleQualitySelection(ctx context.Context, chatID int64, msgID int, userID int64, quality string) {
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
ss.Quality = quality
|
||||
ss.Step = 2
|
||||
|
||||
// Check if we need language selection
|
||||
if len(ss.MediaInfo.Languages) > 1 {
|
||||
kb := languageKeyboard(ss.MediaInfo.Languages)
|
||||
h.editText(ctx, chatID, msgID, "Select language:", &kb)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip to container selection
|
||||
ss.Step = 3
|
||||
containers := dl.ContainerOptions(ss.MediaType)
|
||||
kb := containerKeyboard(containers)
|
||||
h.editText(ctx, chatID, msgID, "Select container format:", &kb)
|
||||
}
|
||||
|
||||
// handleLanguageSelection processes the language choice.
|
||||
func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) {
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
ss.Language = language
|
||||
ss.Step = 3
|
||||
h.setUserStepState(chatID, ss)
|
||||
|
||||
containers := dl.ContainerOptions(ss.MediaType)
|
||||
kb := containerKeyboard(containers)
|
||||
h.editText(ctx, chatID, msgID, "Select container format:", &kb)
|
||||
}
|
||||
|
||||
// handleContainerSelection processes the container format choice and starts the download.
|
||||
func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, msgID int, userID int64, container string) {
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
ss.Container = container
|
||||
|
||||
// Build the format object
|
||||
format := &domain.Format{ID: ss.Quality}
|
||||
for _, f := range ss.MediaInfo.Formats {
|
||||
if f.ID == ss.Quality {
|
||||
format = &f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create the download job
|
||||
job := &domain.DownloadJob{
|
||||
ID: h.generateJobID(),
|
||||
UserID: userID,
|
||||
ChatID: chatID,
|
||||
MessageID: msgID,
|
||||
URL: ss.URL,
|
||||
MediaType: ss.MediaType,
|
||||
SelectedFormat: format,
|
||||
Container: container,
|
||||
Language: ss.Language,
|
||||
Status: domain.StatusDownloading,
|
||||
Title: ss.MediaInfo.Title,
|
||||
FileSize: format.Filesize,
|
||||
}
|
||||
|
||||
h.setActiveJob(userID, job)
|
||||
h.clearStepState(chatID)
|
||||
|
||||
// Start download in background
|
||||
go h.runDownload(ctx, job, msgID)
|
||||
}
|
||||
|
||||
// runDownload manages the download lifecycle, progress updates, and completion.
|
||||
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgID int) {
|
||||
// Show initial progress message
|
||||
kb := progressKeyboard(0, "", "")
|
||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: job.ChatID,
|
||||
Text: fmt.Sprintf("Downloading: %s", job.Title),
|
||||
ReplyMarkup: &kb,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("send progress msg failed", "error", err)
|
||||
return
|
||||
}
|
||||
job.MessageID = msg.ID
|
||||
|
||||
updates, err := h.YtDlp.StartDownload(job, h.downloadDir)
|
||||
if err != nil {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", err.Error()), nil)
|
||||
job.Status = domain.StatusFailed
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
lastProgress := 0.0
|
||||
for upd := range updates {
|
||||
h.mu.Lock()
|
||||
currentJob := h.activeJobs[job.UserID]
|
||||
h.mu.Unlock()
|
||||
if currentJob == nil {
|
||||
// Job was cancelled
|
||||
return
|
||||
}
|
||||
|
||||
if upd.Status == domain.StatusCancelled {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if upd.Status == domain.StatusFailed {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress every ~5%
|
||||
if upd.Progress-lastProgress >= domain.ProgressThreshold || upd.Status == domain.StatusCompleted {
|
||||
lastProgress = upd.Progress
|
||||
|
||||
// Apply quota at >50%
|
||||
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
|
||||
job.QuotaApplied = true
|
||||
h.applyQuota(job.UserID, job.FileSize)
|
||||
}
|
||||
|
||||
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
|
||||
text := fmt.Sprintf("Downloading: %s", job.Title)
|
||||
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
|
||||
}
|
||||
}
|
||||
|
||||
// Download completed
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
|
||||
if job.Status == domain.StatusCompleted {
|
||||
// Find the downloaded file
|
||||
files, err := filepath.Glob(filepath.Join(h.downloadDir, "*"))
|
||||
if err == nil {
|
||||
for _, f := range files {
|
||||
fileInfo, err := os.Stat(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fileInfo.Size() > h.maxFileSize {
|
||||
h.editText(ctx, job.ChatID, job.MessageID,
|
||||
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
|
||||
os.Remove(f)
|
||||
return
|
||||
}
|
||||
// Check file size quota after download
|
||||
if !job.QuotaApplied && job.FileSize > 0 {
|
||||
job.QuotaApplied = true
|
||||
h.applyQuota(job.UserID, job.FileSize)
|
||||
}
|
||||
|
||||
formatID := job.SelectedFormat.ID
|
||||
h.sendDocument(ctx, job.ChatID, f, job.Title)
|
||||
os.Remove(f)
|
||||
|
||||
// Save to history
|
||||
_ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
|
||||
URL: job.URL,
|
||||
Title: job.Title,
|
||||
MediaType: job.MediaType.String(),
|
||||
FormatID: formatID,
|
||||
Container: job.Container,
|
||||
FileSize: job.FileSize,
|
||||
Status: "completed",
|
||||
})
|
||||
|
||||
h.editText(ctx, 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) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
slog.Error("open file for send", "path", filePath, "error", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
doc := &models.InputFileUpload{
|
||||
Filename: title + filepath.Ext(filePath),
|
||||
Data: f,
|
||||
}
|
||||
|
||||
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: doc,
|
||||
}); err != nil {
|
||||
slog.Error("send document failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCancelDownload cancels the current download for a user.
|
||||
func (h *Handler) handleCancelDownload(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
|
||||
job := h.getActiveJob(userID)
|
||||
if job != nil {
|
||||
dl.CancelDownload(job)
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, userID)
|
||||
h.mu.Unlock()
|
||||
h.editText(ctx, chatID, msgID, "Download cancelled.", nil)
|
||||
return
|
||||
}
|
||||
h.clearStepState(chatID)
|
||||
h.editText(ctx, chatID, msgID, "Cancelled.", nil)
|
||||
}
|
||||
|
||||
// handleBackStep goes back one step in the download flow.
|
||||
func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ss.Step--
|
||||
switch ss.Step {
|
||||
case -1:
|
||||
// Go back to URL input
|
||||
h.clearStepState(chatID)
|
||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||
case 0:
|
||||
h.editText(ctx, chatID, msgID, "Select media type:", &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
|
||||
})
|
||||
case 1:
|
||||
// Re-show quality selector
|
||||
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, ss.MediaType)
|
||||
deduped := dl.DeduplicateResolutions(filtered)
|
||||
labels := make([]string, len(deduped))
|
||||
for i, f := range deduped {
|
||||
labels[i] = formatLabelForDisplay(f)
|
||||
}
|
||||
labels = append([]string{"Best"}, labels...)
|
||||
kb := formatKeyboard("quality_", labels)
|
||||
h.editText(ctx, chatID, msgID, "Select quality:", &kb)
|
||||
case 2:
|
||||
if len(ss.MediaInfo.Languages) > 1 {
|
||||
kb := languageKeyboard(ss.MediaInfo.Languages)
|
||||
h.editText(ctx, chatID, msgID, "Select language:", &kb)
|
||||
} else {
|
||||
ss.Step = 1
|
||||
h.handleBackStep(ctx, chatID, msgID, cbID, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadPrompt shows the download prompt (asks for URL).
|
||||
func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
h.promptForInput(ctx, chatID, msgID, userID, PendingURL,
|
||||
"Send me the URL to download:", nil)
|
||||
}
|
||||
502
internal/handler/handler.go
Normal file
502
internal/handler/handler.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"uptodownBot/internal/dl"
|
||||
"uptodownBot/internal/domain"
|
||||
"uptodownBot/internal/repo"
|
||||
)
|
||||
|
||||
type PendingKind string
|
||||
|
||||
const (
|
||||
PendingURL PendingKind = "url"
|
||||
)
|
||||
|
||||
type PendingInput struct {
|
||||
Kind PendingKind
|
||||
UserID int64
|
||||
CreatedAt time.Time
|
||||
MsgID int
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
Bot *tgbot.Bot
|
||||
Repo repo.Repository
|
||||
YtDlp *dl.Client
|
||||
AllowedUsers map[int64]bool
|
||||
activeJobs map[int64]*domain.DownloadJob
|
||||
stepStates map[int64]*stepState
|
||||
lastMsgTime map[int64]time.Time
|
||||
pendingInput map[int64]*PendingInput
|
||||
mu sync.Mutex
|
||||
downloadDir string
|
||||
maxFileSize int64
|
||||
}
|
||||
|
||||
func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed map[int64]bool, downloadDir string, maxFileSize int64) *Handler {
|
||||
h := &Handler{
|
||||
Bot: bot,
|
||||
Repo: store,
|
||||
YtDlp: ytdlp,
|
||||
AllowedUsers: allowed,
|
||||
activeJobs: make(map[int64]*domain.DownloadJob),
|
||||
stepStates: make(map[int64]*stepState),
|
||||
lastMsgTime: make(map[int64]time.Time),
|
||||
pendingInput: make(map[int64]*PendingInput),
|
||||
downloadDir: downloadDir,
|
||||
maxFileSize: maxFileSize,
|
||||
}
|
||||
go h.periodicCleanup()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) periodicCleanup() {
|
||||
for {
|
||||
time.Sleep(domain.CleanupInterval)
|
||||
h.mu.Lock()
|
||||
|
||||
// Clean rate limit entries
|
||||
cutoff := time.Now().Add(-domain.RateLimitInterval * 2)
|
||||
for uid, t := range h.lastMsgTime {
|
||||
if t.Before(cutoff) {
|
||||
delete(h.lastMsgTime, uid)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean stale pending inputs
|
||||
pendingCutoff := time.Now().Add(-5 * time.Minute)
|
||||
for chatID, pi := range h.pendingInput {
|
||||
if pi.CreatedAt.Before(pendingCutoff) {
|
||||
delete(h.pendingInput, chatID)
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.Unlock()
|
||||
|
||||
// Clean old temp files (>1h)
|
||||
h.cleanOldTempFiles()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOldTempFiles() {
|
||||
entries, err := os.ReadDir(h.downloadDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-domain.FileRetention)
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
path := filepath.Join(h.downloadDir, entry.Name())
|
||||
if err := os.Remove(path); err != nil {
|
||||
slog.Warn("failed to remove old temp file", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) isRateLimited(userID int64) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := time.Now()
|
||||
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < domain.RateLimitInterval {
|
||||
return true
|
||||
}
|
||||
h.lastMsgTime[userID] = now
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) getOrCreateUser(chatID int64) (int64, error) {
|
||||
return h.Repo.GetOrCreateUser(chatID)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
||||
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
|
||||
return
|
||||
}
|
||||
if msg.Text == "" {
|
||||
return
|
||||
}
|
||||
userID, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if h.isRateLimited(userID) {
|
||||
return
|
||||
}
|
||||
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
|
||||
|
||||
if msg.Text[0] == '/' {
|
||||
h.mu.Lock()
|
||||
delete(h.pendingInput, msg.Chat.ID)
|
||||
h.mu.Unlock()
|
||||
h.routeCommand(ctx, msg, userID)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
pi, hasPI := h.pendingInput[msg.Chat.ID]
|
||||
if hasPI {
|
||||
delete(h.pendingInput, msg.Chat.ID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if hasPI {
|
||||
h.processPendingInput(ctx, msg, userID, pi)
|
||||
return
|
||||
}
|
||||
|
||||
// Treat plain text as a potential URL
|
||||
h.handleURLInput(ctx, msg.Chat.ID, msg.Text, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, userID int64) {
|
||||
cmd := msg.Text
|
||||
if i := strings.IndexByte(msg.Text, ' '); i >= 0 {
|
||||
cmd = msg.Text[:i]
|
||||
}
|
||||
switch cmd {
|
||||
case "/start":
|
||||
h.handleStart(ctx, msg, userID)
|
||||
case "/settings":
|
||||
h.handleSettings(ctx, msg, userID)
|
||||
case "/help":
|
||||
h.handleHelp(ctx, msg)
|
||||
case "/history":
|
||||
h.handleHistory(ctx, msg, userID)
|
||||
default:
|
||||
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
|
||||
chatID := cb.Message.Message.Chat.ID
|
||||
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] {
|
||||
h.answerCbWithText(ctx, cb.ID, "Unauthorized")
|
||||
return
|
||||
}
|
||||
userID, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
h.answerCb(ctx, cb.ID)
|
||||
return
|
||||
}
|
||||
if h.isRateLimited(userID) {
|
||||
h.answerCb(ctx, cb.ID)
|
||||
return
|
||||
}
|
||||
slog.Info("callback", "chat_id", chatID, "data", cb.Data)
|
||||
|
||||
h.mu.Lock()
|
||||
delete(h.pendingInput, chatID)
|
||||
h.mu.Unlock()
|
||||
|
||||
msgID := cb.Message.Message.ID
|
||||
data := cb.Data
|
||||
|
||||
switch {
|
||||
case data == "noop":
|
||||
h.answerCb(ctx, cb.ID)
|
||||
case data == "cancel_dl":
|
||||
h.handleCancelDownload(ctx, chatID, msgID, cb.ID, userID)
|
||||
case data == "back_step":
|
||||
h.handleBackStep(ctx, chatID, msgID, cb.ID, userID)
|
||||
case data == "back_menu":
|
||||
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
|
||||
case data == "download":
|
||||
h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID)
|
||||
case data == "settings":
|
||||
h.settingsCallback(ctx, chatID, msgID, cb.ID, userID)
|
||||
case data == "help":
|
||||
h.handleHelp(ctx, cb.Message.Message)
|
||||
h.answerCb(ctx, cb.ID)
|
||||
case data == "history":
|
||||
h.handleHistory(ctx, cb.Message.Message, userID)
|
||||
h.answerCb(ctx, cb.ID)
|
||||
case data == "pending_cancel":
|
||||
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
|
||||
default:
|
||||
h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, userID, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, userID int64, data string) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(data, "type_"):
|
||||
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
|
||||
case strings.HasPrefix(data, "quality_"):
|
||||
h.handleQualitySelection(ctx, chatID, msgID, userID, data[8:])
|
||||
case strings.HasPrefix(data, "lang_"):
|
||||
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
||||
case strings.HasPrefix(data, "container_"):
|
||||
h.handleContainerSelection(ctx, chatID, msgID, userID, data[10:])
|
||||
case strings.HasPrefix(data, "pl_page_"):
|
||||
h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:])
|
||||
case strings.HasPrefix(data, "pl_entry_"):
|
||||
h.handlePlaylistEntry(ctx, chatID, msgID, userID, data[9:])
|
||||
case data == "pl_select_all":
|
||||
h.handlePlaylistSelectAll(ctx, chatID, msgID, userID)
|
||||
case data == "pl_confirm":
|
||||
h.handlePlaylistConfirm(ctx, chatID, msgID, userID)
|
||||
case data == "back_settings":
|
||||
h.backToSettings(ctx, chatID, msgID, userID)
|
||||
case data == "delaccount_prompt":
|
||||
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
|
||||
case strings.HasPrefix(data, "delaccount_"):
|
||||
h.deleteAccountConfirm(ctx, chatID, msgID, userID)
|
||||
case strings.HasPrefix(data, "settings_"):
|
||||
h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:])
|
||||
case strings.HasPrefix(data, "settings_set_"):
|
||||
h.handleSettingsSet(ctx, chatID, msgID, userID, data[13:])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) {
|
||||
h.mu.Lock()
|
||||
h.pendingInput[chatID] = &PendingInput{
|
||||
Kind: kind,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now(),
|
||||
MsgID: msgID,
|
||||
Data: data,
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, userID int64, pi *PendingInput) {
|
||||
text := strings.TrimSpace(msg.Text)
|
||||
switch pi.Kind {
|
||||
case PendingURL:
|
||||
h.handleURLInput(ctx, msg.Chat.ID, text, userID)
|
||||
default:
|
||||
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
|
||||
kb := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Cancel", CallbackData: "pending_cancel"}},
|
||||
},
|
||||
}
|
||||
if msgID == 0 {
|
||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: chatID, Text: prompt, ReplyMarkup: kb,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("send prompt failed", "chat_id", chatID, "error", err)
|
||||
return
|
||||
}
|
||||
h.setPending(ctx, chatID, msg.ID, userID, kind, data)
|
||||
} else {
|
||||
h.editText(ctx, chatID, msgID, prompt, &kb)
|
||||
h.setPending(ctx, chatID, msgID, userID, kind, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
|
||||
if msgID == 0 {
|
||||
if kb != nil {
|
||||
h.sendWithKB(ctx, chatID, text, *kb)
|
||||
} else {
|
||||
h.sendText(ctx, chatID, text)
|
||||
}
|
||||
} else {
|
||||
h.editText(ctx, chatID, msgID, text, kb)
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers --
|
||||
|
||||
func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
|
||||
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
|
||||
slog.Warn("send text failed", "chat_id", chatID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
|
||||
p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text}
|
||||
if kb != nil {
|
||||
p.ReplyMarkup = kb
|
||||
}
|
||||
if _, err := h.Bot.EditMessageText(ctx, p); err != nil {
|
||||
slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) {
|
||||
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil {
|
||||
slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) answerCb(ctx context.Context, callbackID string) {
|
||||
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
|
||||
slog.Debug("answer callback failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) {
|
||||
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil {
|
||||
slog.Debug("answer callback with text failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
h.clearActiveJob(userID)
|
||||
h.editText(ctx, chatID, msgID, "Main Menu:", nil)
|
||||
h.sendWithKB(ctx, chatID, "Main Menu:", mainKeyboard())
|
||||
}
|
||||
|
||||
func (h *Handler) clearActiveJob(userID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if job, ok := h.activeJobs[userID]; ok {
|
||||
dl.CancelDownload(job)
|
||||
delete(h.activeJobs, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getActiveJob(userID int64) *domain.DownloadJob {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.activeJobs[userID]
|
||||
}
|
||||
|
||||
func (h *Handler) setActiveJob(userID int64, job *domain.DownloadJob) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.activeJobs[userID] = job
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return strconv.FormatInt(bytes, 10) + "B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) generateJobID() string {
|
||||
return uuid.NewString()[:8]
|
||||
}
|
||||
|
||||
// handleStart shows the main menu.
|
||||
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, userID int64) {
|
||||
slog.Info("start", "user_id", userID, "chat_id", msg.Chat.ID)
|
||||
h.sendWithKB(ctx, msg.Chat.ID, "Main Menu:", mainKeyboard())
|
||||
}
|
||||
|
||||
// handleHelp shows the help text.
|
||||
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
|
||||
text := `Commands:
|
||||
/start - Show main menu
|
||||
/settings - Open settings
|
||||
/help - Show this help
|
||||
/history - View download history
|
||||
|
||||
Just send me a URL to start downloading.`
|
||||
h.sendText(ctx, msg.Chat.ID, text)
|
||||
}
|
||||
|
||||
// handleSettings opens the settings menu.
|
||||
func (h *Handler) handleSettings(ctx context.Context, msg *models.Message, userID int64) {
|
||||
prefs, err := h.Repo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
|
||||
return
|
||||
}
|
||||
text, kb := h.buildSettingsKeyboard(userID, prefs)
|
||||
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
||||
}
|
||||
|
||||
// handleHistory opens the download history.
|
||||
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, userID int64) {
|
||||
records, err := h.Repo.GetHistory(userID, 10, 0)
|
||||
if err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading history")
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
h.sendText(ctx, msg.Chat.ID, "No download history yet.")
|
||||
return
|
||||
}
|
||||
text := "Recent downloads:\n"
|
||||
for i, r := range records {
|
||||
text += fmt.Sprintf("%d. %s (%s)\n", i+1, r.Title, formatBytes(r.FileSize))
|
||||
}
|
||||
h.sendText(ctx, msg.Chat.ID, text)
|
||||
}
|
||||
|
||||
// applyQuota adds file size to the user's quota counters.
|
||||
func (h *Handler) applyQuota(userID int64, fileSize int64) {
|
||||
q, err := h.Repo.GetOrCreateQuotas(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q = h.resetQuotasIfNeeded(q)
|
||||
sizeMB := fileSize / (1024 * 1024)
|
||||
if sizeMB < 1 {
|
||||
sizeMB = 1
|
||||
}
|
||||
q.DailyUsedMB += sizeMB
|
||||
q.WeeklyUsedMB += sizeMB
|
||||
q.MonthlyUsedMB += sizeMB
|
||||
_ = h.Repo.UpdateQuotas(userID, q)
|
||||
}
|
||||
|
||||
// resetQuotasIfNeeded resets quota counters if the period has changed.
|
||||
func (h *Handler) resetQuotasIfNeeded(q *domain.UserQuotas) *domain.UserQuotas {
|
||||
today := time.Now().UTC().Format(domain.DateLayout)
|
||||
_, weekNum := time.Now().UTC().ISOWeek()
|
||||
weekStart := fmt.Sprintf("%d-W%02d", time.Now().UTC().Year(), weekNum)
|
||||
monthStart := time.Now().UTC().Format("2006-01")
|
||||
|
||||
if q.DailyDate != today {
|
||||
q.DailyUsedMB = 0
|
||||
q.DailyDate = today
|
||||
}
|
||||
if q.WeekStart != weekStart {
|
||||
q.WeeklyUsedMB = 0
|
||||
q.WeekStart = weekStart
|
||||
}
|
||||
if q.MonthStart != monthStart {
|
||||
q.MonthlyUsedMB = 0
|
||||
q.MonthStart = monthStart
|
||||
}
|
||||
return q
|
||||
}
|
||||
256
internal/handler/keyboard.go
Normal file
256
internal/handler/keyboard.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
func mainKeyboard() models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "Download", CallbackData: "download"},
|
||||
{Text: "Settings", CallbackData: "settings"},
|
||||
},
|
||||
{
|
||||
{Text: "History", CallbackData: "history"},
|
||||
{Text: "Help", CallbackData: "help"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func backKeyboard() models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func settingsBackKeyboard() models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Settings", CallbackData: "back_settings"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mediaTypeKeyboard builds the three-button row for media type selection + Cancel.
|
||||
func mediaTypeKeyboard() models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "Audio Only", CallbackData: "type_audio"},
|
||||
{Text: "Video Only", CallbackData: "type_video"},
|
||||
{Text: "Video+Audio", CallbackData: "type_both"},
|
||||
},
|
||||
{{Text: "Cancel", CallbackData: "cancel_dl"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// formatKeyboard builds a keyboard from a slice of format labels.
|
||||
func formatKeyboard(prefix string, labels []string) models.InlineKeyboardMarkup {
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, label := range labels {
|
||||
data := prefix + label
|
||||
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: data})
|
||||
if len(row) == 2 || i == len(labels)-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}
|
||||
}
|
||||
|
||||
// languageKeyboard builds a keyboard from available languages.
|
||||
func languageKeyboard(languages []string) models.InlineKeyboardMarkup {
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, lang := range languages {
|
||||
data := "lang_" + lang
|
||||
row = append(row, models.InlineKeyboardButton{Text: lang, CallbackData: data})
|
||||
if len(row) == 2 || i == len(languages)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back", CallbackData: "back_step"},
|
||||
{Text: "Cancel", CallbackData: "cancel_dl"},
|
||||
})
|
||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
// containerKeyboard builds keyboard for container format selection.
|
||||
func containerKeyboard(containers []string) models.InlineKeyboardMarkup {
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, c := range containers {
|
||||
data := "container_" + c
|
||||
row = append(row, models.InlineKeyboardButton{Text: strings.ToUpper(c), CallbackData: data})
|
||||
if len(row) == 3 || i == len(containers)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back", CallbackData: "back_step"},
|
||||
{Text: "Cancel", CallbackData: "cancel_dl"},
|
||||
})
|
||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
// progressKeyboard builds the progress display with a dummy button and cancel.
|
||||
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
|
||||
progressText := fmt.Sprintf("%.1f%%", pct)
|
||||
if speed != "" {
|
||||
progressText += fmt.Sprintf(" | %s/s", speed)
|
||||
}
|
||||
if eta != "" {
|
||||
progressText += fmt.Sprintf(" | ETA %s", eta)
|
||||
}
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: progressText, CallbackData: "noop"}},
|
||||
{{Text: "Cancel", CallbackData: "cancel_dl"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildToggleKeyboard creates an on/off picker for a boolean setting.
|
||||
// Matches worktimeBot's pattern with "> " prefix for the current selection.
|
||||
func buildToggleKeyboard(userID int64, setting, label string, st *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
current := false
|
||||
switch setting {
|
||||
case "quality":
|
||||
current = st.DefaultQuality == "best"
|
||||
case "container":
|
||||
current = st.DefaultContainer == "mp4"
|
||||
}
|
||||
options := []struct {
|
||||
label string
|
||||
data string
|
||||
value bool
|
||||
}{
|
||||
{"Enabled", setting + "_enable", true},
|
||||
{"Disabled", setting + "_disable", false},
|
||||
}
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
for _, opt := range options {
|
||||
l := opt.label
|
||||
if opt.value == current {
|
||||
l = "> " + l
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: l, CallbackData: opt.data},
|
||||
})
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||
})
|
||||
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
// playlistNavKeyboard builds the paginated navigation for playlist selection.
|
||||
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup {
|
||||
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
// Build entry rows (2 per row)
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
start := state.Page * state.PerPage
|
||||
end := start + state.PerPage
|
||||
if end > len(state.Entries) {
|
||||
end = len(state.Entries)
|
||||
}
|
||||
|
||||
for i := start; i < end; i += 2 {
|
||||
row := []models.InlineKeyboardButton{}
|
||||
// First entry
|
||||
e := state.Entries[i]
|
||||
prefix := " "
|
||||
if state.Selected[e.ID] {
|
||||
prefix = "> "
|
||||
}
|
||||
// Truncate title for button
|
||||
title := e.Title
|
||||
if len(title) > 30 {
|
||||
title = title[:27] + "..."
|
||||
}
|
||||
row = append(row, models.InlineKeyboardButton{
|
||||
Text: prefix + title, CallbackData: "pl_entry_" + e.ID,
|
||||
})
|
||||
// Second entry if available
|
||||
if i+1 < end {
|
||||
e2 := state.Entries[i+1]
|
||||
prefix2 := " "
|
||||
if state.Selected[e2.ID] {
|
||||
prefix2 = "> "
|
||||
}
|
||||
title2 := e2.Title
|
||||
if len(title2) > 30 {
|
||||
title2 = title2[:27] + "..."
|
||||
}
|
||||
row = append(row, models.InlineKeyboardButton{
|
||||
Text: prefix2 + title2, CallbackData: "pl_entry_" + e2.ID,
|
||||
})
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// Navigation row
|
||||
navRow := []models.InlineKeyboardButton{}
|
||||
navRow = append(navRow, models.InlineKeyboardButton{
|
||||
Text: "<", CallbackData: fmt.Sprintf("pl_page_%d", state.Page-1),
|
||||
})
|
||||
navRow = append(navRow, models.InlineKeyboardButton{
|
||||
Text: fmt.Sprintf("Page %d/%d", state.Page+1, totalPages), CallbackData: "noop",
|
||||
})
|
||||
navRow = append(navRow, models.InlineKeyboardButton{
|
||||
Text: ">", CallbackData: fmt.Sprintf("pl_page_%d", state.Page+1),
|
||||
})
|
||||
rows = append(rows, navRow)
|
||||
|
||||
// Action row
|
||||
actionRow := []models.InlineKeyboardButton{
|
||||
{Text: "Select All", CallbackData: "pl_select_all"},
|
||||
}
|
||||
if selectedCount > 0 {
|
||||
actionRow = append(actionRow, models.InlineKeyboardButton{
|
||||
Text: fmt.Sprintf("Confirm (%d)", selectedCount), CallbackData: "pl_confirm",
|
||||
})
|
||||
}
|
||||
rows = append(rows, actionRow)
|
||||
|
||||
// Cancel
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Cancel", CallbackData: "cancel_dl"},
|
||||
})
|
||||
|
||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
// deleteConfirmKeyboard builds the confirmation prompt for account deletion.
|
||||
func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
|
||||
return models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", userID)},
|
||||
{Text: "Cancel", CallbackData: "back_settings"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
195
internal/handler/playlist.go
Normal file
195
internal/handler/playlist.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo) {
|
||||
state := &domain.PlaylistState{
|
||||
Entries: info.PlaylistEntries,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
Selected: make(map[string]bool),
|
||||
}
|
||||
|
||||
ss := &stepState{
|
||||
URL: url,
|
||||
MediaInfo: info,
|
||||
PlaylistState: state,
|
||||
}
|
||||
h.setUserStepState(chatID, ss)
|
||||
|
||||
kb := playlistNavKeyboard(state, 0)
|
||||
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
|
||||
}
|
||||
|
||||
func (h *Handler) getPlaylistState(chatID int64) *domain.PlaylistState {
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
return nil
|
||||
}
|
||||
return ss.PlaylistState
|
||||
}
|
||||
|
||||
func (h *Handler) handlePlaylistPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
|
||||
state := h.getPlaylistState(chatID)
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
|
||||
if page < 0 || page >= totalPages {
|
||||
return
|
||||
}
|
||||
state.Page = page
|
||||
|
||||
selectedCount := countSelected(state)
|
||||
kb := playlistNavKeyboard(state, selectedCount)
|
||||
ss := h.getUserStepState(chatID)
|
||||
title := ""
|
||||
if ss != nil {
|
||||
title = ss.MediaInfo.Title
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) handlePlaylistEntry(ctx context.Context, chatID int64, msgID int, userID int64, entryID string) {
|
||||
state := h.getPlaylistState(chatID)
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.Selected[entryID] = !state.Selected[entryID]
|
||||
|
||||
selectedCount := countSelected(state)
|
||||
kb := playlistNavKeyboard(state, selectedCount)
|
||||
ss := h.getUserStepState(chatID)
|
||||
title := ""
|
||||
if ss != nil {
|
||||
title = ss.MediaInfo.Title
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) handlePlaylistSelectAll(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
state := h.getPlaylistState(chatID)
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
|
||||
allSelected := true
|
||||
for _, e := range state.Entries {
|
||||
if !state.Selected[e.ID] {
|
||||
allSelected = false
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, e := range state.Entries {
|
||||
state.Selected[e.ID] = !allSelected
|
||||
}
|
||||
|
||||
selectedCount := 0
|
||||
if !allSelected {
|
||||
selectedCount = len(state.Entries)
|
||||
}
|
||||
kb := playlistNavKeyboard(state, selectedCount)
|
||||
ss := h.getUserStepState(chatID)
|
||||
title := ""
|
||||
if ss != nil {
|
||||
title = ss.MediaInfo.Title
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
state := h.getPlaylistState(chatID)
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var selected []domain.PlaylistEntry
|
||||
for _, e := range state.Entries {
|
||||
if state.Selected[e.ID] {
|
||||
selected = append(selected, e)
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
h.editText(ctx, chatID, msgID, "No videos selected.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.clearStepState(chatID)
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos sequentially...", len(selected)), nil)
|
||||
|
||||
for i, entry := range selected {
|
||||
h.sendText(ctx, chatID, fmt.Sprintf("Downloading (%d/%d): %s", i+1, len(selected), entry.Title))
|
||||
|
||||
info, err := h.YtDlp.GetMediaInfo(entry.URL)
|
||||
if err != nil {
|
||||
h.sendText(ctx, chatID, fmt.Sprintf("Failed: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||
if prefErr != nil {
|
||||
prefs = &domain.UserPreferences{
|
||||
DefaultMediaType: domain.MediaTypeVideoAudio,
|
||||
DefaultQuality: "best",
|
||||
DefaultContainer: "mp4",
|
||||
}
|
||||
}
|
||||
|
||||
format := &domain.Format{ID: prefs.DefaultQuality}
|
||||
for _, f := range info.Formats {
|
||||
if f.ID == prefs.DefaultQuality {
|
||||
format = &f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
job := &domain.DownloadJob{
|
||||
ID: h.generateJobID(),
|
||||
UserID: userID,
|
||||
ChatID: chatID,
|
||||
URL: entry.URL,
|
||||
MediaType: prefs.DefaultMediaType,
|
||||
SelectedFormat: format,
|
||||
Container: prefs.DefaultContainer,
|
||||
Language: prefs.DefaultLanguage,
|
||||
Status: domain.StatusDownloading,
|
||||
Title: entry.Title,
|
||||
FileSize: format.Filesize,
|
||||
}
|
||||
|
||||
h.setActiveJob(userID, job)
|
||||
h.runDownload(ctx, job, 0)
|
||||
|
||||
// Wait for active job to complete
|
||||
for {
|
||||
j := h.getActiveJob(userID)
|
||||
if j == nil || j.Status == domain.StatusCompleted || j.Status == domain.StatusFailed || j.Status == domain.StatusCancelled {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
h.sendText(ctx, chatID, "All playlist downloads completed.")
|
||||
}
|
||||
|
||||
func countSelected(state *domain.PlaylistState) int {
|
||||
count := 0
|
||||
for _, s := range state.Selected {
|
||||
if s {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
246
internal/handler/settings.go
Normal file
246
internal/handler/settings.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
mediaTypeLabel := "Video+Audio"
|
||||
switch prefs.DefaultMediaType {
|
||||
case domain.MediaTypeAudio:
|
||||
mediaTypeLabel = "Audio Only"
|
||||
case domain.MediaTypeVideo:
|
||||
mediaTypeLabel = "Video Only"
|
||||
}
|
||||
qualityLabel := prefs.DefaultQuality
|
||||
if qualityLabel == "" {
|
||||
qualityLabel = "best"
|
||||
}
|
||||
containerLabel := prefs.DefaultContainer
|
||||
if containerLabel == "" {
|
||||
containerLabel = "mp4"
|
||||
}
|
||||
langLabel := prefs.DefaultLanguage
|
||||
if langLabel == "" {
|
||||
langLabel = "Default"
|
||||
}
|
||||
|
||||
rows := [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: fmt.Sprintf("Type: %s", mediaTypeLabel), CallbackData: "settings_media_type"},
|
||||
{Text: fmt.Sprintf("Quality: %s", qualityLabel), CallbackData: "settings_quality"},
|
||||
},
|
||||
{
|
||||
{Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"},
|
||||
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
|
||||
},
|
||||
{{Text: "Delete my data", CallbackData: "delaccount_prompt"}},
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
}
|
||||
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
prefs, err := h.Repo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text, kb := h.buildSettingsKeyboard(userID, prefs)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
prefs, err := h.Repo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text, kb := h.buildSettingsKeyboard(userID, prefs)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
|
||||
prefs, err := h.Repo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch setting {
|
||||
case "media_type":
|
||||
text, kb := h.buildMediaTypePicker(prefs)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
case "quality":
|
||||
text, kb := h.buildQualityPicker(prefs)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
case "container":
|
||||
text, kb := h.buildContainerPicker(prefs)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
case "language":
|
||||
text, kb := h.buildLanguagePicker(prefs)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
options := []struct {
|
||||
label string
|
||||
value domain.MediaType
|
||||
data string
|
||||
}{
|
||||
{"Audio Only", domain.MediaTypeAudio, "settings_set_type_audio"},
|
||||
{"Video Only", domain.MediaTypeVideo, "settings_set_type_video"},
|
||||
{"Video+Audio", domain.MediaTypeVideoAudio, "settings_set_type_both"},
|
||||
}
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
for _, opt := range options {
|
||||
label := opt.label
|
||||
if opt.value == prefs.DefaultMediaType {
|
||||
label = "> " + label
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: label, CallbackData: opt.data},
|
||||
})
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||
})
|
||||
return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (h *Handler) buildQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
qualities := []string{"best", "2160p", "1080p", "720p", "480p", "360p"}
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, q := range qualities {
|
||||
label := q
|
||||
if q == prefs.DefaultQuality {
|
||||
label = "> " + label
|
||||
}
|
||||
row = append(row, models.InlineKeyboardButton{
|
||||
Text: label, CallbackData: "settings_set_quality_" + q,
|
||||
})
|
||||
if len(row) == 3 || i == len(qualities)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||
})
|
||||
return "Select default quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
containers := []string{"mp4", "mkv", "webm", "mp3", "m4a", "opus"}
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, c := range containers {
|
||||
label := c
|
||||
if c == prefs.DefaultContainer {
|
||||
label = "> " + label
|
||||
}
|
||||
row = append(row, models.InlineKeyboardButton{
|
||||
Text: label, CallbackData: "settings_set_container_" + c,
|
||||
})
|
||||
if len(row) == 3 || i == len(containers)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||
})
|
||||
return "Select default container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"}
|
||||
langLabels := map[string]string{
|
||||
"": "Default", "en": "English", "es": "Spanish", "ja": "Japanese",
|
||||
"ko": "Korean", "fr": "French", "de": "German", "zh": "Chinese",
|
||||
"ar": "Arabic", "pt": "Portuguese", "ru": "Russian",
|
||||
}
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
row := []models.InlineKeyboardButton{}
|
||||
for i, l := range languages {
|
||||
label := langLabels[l]
|
||||
if l == prefs.DefaultLanguage {
|
||||
label = "> " + label
|
||||
}
|
||||
row = append(row, models.InlineKeyboardButton{
|
||||
Text: label, CallbackData: "settings_set_language_" + l,
|
||||
})
|
||||
if len(row) == 2 || i == len(languages)-1 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||
})
|
||||
return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
|
||||
parts := strings.SplitN(setting, "_", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
action := parts[0]
|
||||
value := parts[1]
|
||||
|
||||
prefs, err := h.Repo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
changed := false
|
||||
switch action {
|
||||
case "type":
|
||||
switch value {
|
||||
case "audio":
|
||||
prefs.DefaultMediaType = domain.MediaTypeAudio
|
||||
case "video":
|
||||
prefs.DefaultMediaType = domain.MediaTypeVideo
|
||||
case "both":
|
||||
prefs.DefaultMediaType = domain.MediaTypeVideoAudio
|
||||
}
|
||||
changed = true
|
||||
case "quality":
|
||||
prefs.DefaultQuality = value
|
||||
changed = true
|
||||
case "container":
|
||||
prefs.DefaultContainer = value
|
||||
changed = true
|
||||
case "language":
|
||||
prefs.DefaultLanguage = value
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = h.Repo.UpdatePreferences(userID, prefs)
|
||||
}
|
||||
h.backToSettings(ctx, chatID, msgID, userID)
|
||||
}
|
||||
|
||||
// deleteAccountPrompt shows the deletion confirmation prompt.
|
||||
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
kb := deleteConfirmKeyboard(userID)
|
||||
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (preferences, quota, history).\nAre you sure?", &kb)
|
||||
}
|
||||
|
||||
// deleteAccountConfirm permanently deletes all user data.
|
||||
func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
if err := h.Repo.DeleteUserData(userID); err != nil {
|
||||
slog.Error("delete user data", "user_id", userID, "error", err)
|
||||
h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil)
|
||||
return
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil)
|
||||
}
|
||||
27
internal/repo/interface.go
Normal file
27
internal/repo/interface.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package repo
|
||||
|
||||
import "uptodownBot/internal/domain"
|
||||
|
||||
type Repository interface {
|
||||
// Users
|
||||
GetOrCreateUser(chatID int64) (int64, error)
|
||||
|
||||
// Preferences
|
||||
GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error)
|
||||
UpdatePreferences(userID int64, prefs *domain.UserPreferences) error
|
||||
|
||||
// Quotas
|
||||
GetOrCreateQuotas(userID int64) (*domain.UserQuotas, error)
|
||||
UpdateQuotas(userID int64, q *domain.UserQuotas) error
|
||||
|
||||
// Download history
|
||||
AddHistory(userID int64, record *domain.DownloadRecord) error
|
||||
GetHistory(userID int64, limit, offset int) ([]domain.DownloadRecord, error)
|
||||
GetHistoryCount(userID int64) (int, error)
|
||||
|
||||
// Account
|
||||
DeleteUserData(userID int64) error
|
||||
|
||||
// Lifecycle
|
||||
Close() error
|
||||
}
|
||||
51
internal/repo/migrations/001_init.sql
Normal file
51
internal/repo/migrations/001_init.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL UNIQUE,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
||||
default_media_type TEXT NOT NULL DEFAULT 'video_audio',
|
||||
default_quality TEXT NOT NULL DEFAULT 'best',
|
||||
default_container TEXT NOT NULL DEFAULT 'mp4',
|
||||
default_language TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_quotas (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
||||
daily_limit_mb INTEGER NOT NULL DEFAULT 100,
|
||||
daily_used_mb INTEGER NOT NULL DEFAULT 0,
|
||||
daily_date TEXT NOT NULL DEFAULT '',
|
||||
weekly_limit_mb INTEGER NOT NULL DEFAULT 500,
|
||||
weekly_used_mb INTEGER NOT NULL DEFAULT 0,
|
||||
week_start TEXT NOT NULL DEFAULT '',
|
||||
monthly_limit_mb INTEGER NOT NULL DEFAULT 2000,
|
||||
monthly_used_mb INTEGER NOT NULL DEFAULT 0,
|
||||
month_start TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
format_id TEXT NOT NULL,
|
||||
container TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_user ON download_history(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_user_created ON download_history(user_id, created_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS download_history;
|
||||
DROP TABLE IF EXISTS user_quotas;
|
||||
DROP TABLE IF EXISTS user_preferences;
|
||||
DROP TABLE IF EXISTS users;
|
||||
219
internal/repo/store.go
Normal file
219
internal/repo/store.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"log/slog"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
slog.Warn("failed to set WAL mode", "error", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func runMigrations(db *sql.DB) error {
|
||||
goose.SetBaseFS(migrationFS)
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) GetOrCreateUser(chatID int64) (int64, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO users (chat_id) VALUES (?)",
|
||||
chatID,
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
err := s.db.QueryRow("SELECT id FROM users WHERE chat_id = ?", chatID).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)",
|
||||
userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getPreferences(userID)
|
||||
}
|
||||
|
||||
func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT default_media_type, default_quality, default_container, default_language
|
||||
FROM user_preferences WHERE user_id = ?
|
||||
`, userID)
|
||||
var p domain.UserPreferences
|
||||
var mediaType string
|
||||
if err := row.Scan(&mediaType, &p.DefaultQuality, &p.DefaultContainer, &p.DefaultLanguage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch mediaType {
|
||||
case "audio":
|
||||
p.DefaultMediaType = domain.MediaTypeAudio
|
||||
case "video":
|
||||
p.DefaultMediaType = domain.MediaTypeVideo
|
||||
default:
|
||||
p.DefaultMediaType = domain.MediaTypeVideoAudio
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func mediaTypeToString(mt domain.MediaType) string {
|
||||
switch mt {
|
||||
case domain.MediaTypeAudio:
|
||||
return "audio"
|
||||
case domain.MediaTypeVideo:
|
||||
return "video"
|
||||
default:
|
||||
return "video_audio"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePreferences(userID int64, prefs *domain.UserPreferences) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE user_preferences SET default_media_type=?, default_quality=?,
|
||||
default_container=?, default_language=?
|
||||
WHERE user_id=?
|
||||
`, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultQuality,
|
||||
prefs.DefaultContainer, prefs.DefaultLanguage, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetOrCreateQuotas(userID int64) (*domain.UserQuotas, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_quotas (user_id) VALUES (?)",
|
||||
userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getQuotas(userID)
|
||||
}
|
||||
|
||||
func (s *Store) getQuotas(userID int64) (*domain.UserQuotas, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT daily_limit_mb, daily_used_mb, daily_date,
|
||||
weekly_limit_mb, weekly_used_mb, week_start,
|
||||
monthly_limit_mb, monthly_used_mb, month_start
|
||||
FROM user_quotas WHERE user_id = ?
|
||||
`, userID)
|
||||
var q domain.UserQuotas
|
||||
err := row.Scan(
|
||||
&q.DailyLimitMB, &q.DailyUsedMB, &q.DailyDate,
|
||||
&q.WeeklyLimitMB, &q.WeeklyUsedMB, &q.WeekStart,
|
||||
&q.MonthlyLimitMB, &q.MonthlyUsedMB, &q.MonthStart,
|
||||
)
|
||||
return &q, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateQuotas(userID int64, q *domain.UserQuotas) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE user_quotas SET daily_limit_mb=?, daily_used_mb=?, daily_date=?,
|
||||
weekly_limit_mb=?, weekly_used_mb=?, week_start=?,
|
||||
monthly_limit_mb=?, monthly_used_mb=?, month_start=?,
|
||||
updated_at=datetime('now')
|
||||
WHERE user_id=?
|
||||
`, q.DailyLimitMB, q.DailyUsedMB, q.DailyDate,
|
||||
q.WeeklyLimitMB, q.WeeklyUsedMB, q.WeekStart,
|
||||
q.MonthlyLimitMB, q.MonthlyUsedMB, q.MonthStart,
|
||||
userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AddHistory(userID int64, record *domain.DownloadRecord) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO download_history (user_id, url, title, media_type, format_id, container, file_size, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, userID, record.URL, record.Title, record.MediaType, record.FormatID,
|
||||
record.Container, record.FileSize, record.Status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetHistory(userID int64, limit, offset int) ([]domain.DownloadRecord, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, url, title, media_type, format_id, container, file_size, status, created_at
|
||||
FROM download_history
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []domain.DownloadRecord
|
||||
for rows.Next() {
|
||||
var r domain.DownloadRecord
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.UserID, &r.URL, &r.Title, &r.MediaType,
|
||||
&r.FormatID, &r.Container, &r.FileSize, &r.Status, &r.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetHistoryCount(userID int64) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(1) FROM download_history WHERE user_id=?", userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserData(userID int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM download_history WHERE user_id=?", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM user_quotas WHERE user_id=?", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM user_preferences WHERE user_id=?", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM users WHERE id=?", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
Reference in New Issue
Block a user