commit 05fcdf74585fcad1df4182aa5611fc81a49ebc78 Author: db123 Date: Thu Jun 25 14:22:48 2026 +0330 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..96202d1 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d8d304 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.build/ +*.exe +*.db +*.db-wal +*.db-shm +.env +.DS_Store +tmp/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f23c71 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..443990f --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..38db0ec --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..ef8e063 --- /dev/null +++ b/cmd/bot/main.go @@ -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 +} diff --git a/cmd/bot/webhook.go b/cmd/bot/webhook.go new file mode 100644 index 0000000..7aa58f0 --- /dev/null +++ b/cmd/bot/webhook.go @@ -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) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dccad19 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5699831 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f21f460 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/dl/progress.go b/internal/dl/progress.go new file mode 100644 index 0000000..57d7fac --- /dev/null +++ b/internal/dl/progress.go @@ -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 " + 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 " + 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 + } +} diff --git a/internal/dl/progress_test.go b/internal/dl/progress_test.go new file mode 100644 index 0000000..44b968d --- /dev/null +++ b/internal/dl/progress_test.go @@ -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) + } + } +} diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go new file mode 100644 index 0000000..87c27e5 --- /dev/null +++ b/internal/dl/ytdlp.go @@ -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"} + } +} diff --git a/internal/dl/ytdlp_test.go b/internal/dl/ytdlp_test.go new file mode 100644 index 0000000..92f721d --- /dev/null +++ b/internal/dl/ytdlp_test.go @@ -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) + } + } +} diff --git a/internal/domain/constants.go b/internal/domain/constants.go new file mode 100644 index 0000000..03ba98c --- /dev/null +++ b/internal/domain/constants.go @@ -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 +) diff --git a/internal/domain/sanitize.go b/internal/domain/sanitize.go new file mode 100644 index 0000000..eeba48a --- /dev/null +++ b/internal/domain/sanitize.go @@ -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 +} diff --git a/internal/domain/sanitize_test.go b/internal/domain/sanitize_test.go new file mode 100644 index 0000000..62fe75e --- /dev/null +++ b/internal/domain/sanitize_test.go @@ -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) + } + } +} diff --git a/internal/domain/types.go b/internal/domain/types.go new file mode 100644 index 0000000..a507519 --- /dev/null +++ b/internal/domain/types.go @@ -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 +} diff --git a/internal/handler/download.go b/internal/handler/download.go new file mode 100644 index 0000000..e02e40a --- /dev/null +++ b/internal/handler/download.go @@ -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) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..578bffd --- /dev/null +++ b/internal/handler/handler.go @@ -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 +} diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go new file mode 100644 index 0000000..d57dffd --- /dev/null +++ b/internal/handler/keyboard.go @@ -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"}, + }, + }, + } +} diff --git a/internal/handler/playlist.go b/internal/handler/playlist.go new file mode 100644 index 0000000..c05af3f --- /dev/null +++ b/internal/handler/playlist.go @@ -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 +} diff --git a/internal/handler/settings.go b/internal/handler/settings.go new file mode 100644 index 0000000..1c2e298 --- /dev/null +++ b/internal/handler/settings.go @@ -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) +} diff --git a/internal/repo/interface.go b/internal/repo/interface.go new file mode 100644 index 0000000..520cf9c --- /dev/null +++ b/internal/repo/interface.go @@ -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 +} diff --git a/internal/repo/migrations/001_init.sql b/internal/repo/migrations/001_init.sql new file mode 100644 index 0000000..fbd8f10 --- /dev/null +++ b/internal/repo/migrations/001_init.sql @@ -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; diff --git a/internal/repo/store.go b/internal/repo/store.go new file mode 100644 index 0000000..15c23b0 --- /dev/null +++ b/internal/repo/store.go @@ -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() +}