From 78a196634a97d7fd577c0001fea2ab8ec3173783 Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 16:35:42 +0530 Subject: [PATCH 01/39] feat: initialize StreamGO workspace with Chi router, MongoDB v2, and gotd MTProto client --- .env.example | 18 ++++ .gitignore | 35 ++++++++ README.md | 65 +++++++++++++++ cmd/server/main.go | 106 ++++++++++++++++++++++++ go.mod | 57 +++++++++++++ go.sum | 154 +++++++++++++++++++++++++++++++++++ internal/config/config.go | 82 +++++++++++++++++++ internal/database/mongodb.go | 69 ++++++++++++++++ internal/server/router.go | 97 ++++++++++++++++++++++ internal/telegram/client.go | 134 ++++++++++++++++++++++++++++++ 10 files changed, 817 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/mongodb.go create mode 100644 internal/server/router.go create mode 100644 internal/telegram/client.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..abee8de --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Server Configuration +PORT=8000 +DEBUG=false +CORS_ORIGIN=* + +# MongoDB Configuration +MONGO_URI=mongodb://localhost:27017 +DATABASE_NAME=Stream + +# Telegram Credentials +API_ID=0 +API_HASH= +BOT_TOKEN= +SESSION_STRING= + +# Channel IDs (optional) +CHANNEL_ID=0 +DUMP_CHANNEL_ID=0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2405c78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Local environment and secret files +.env +*.local.env + +# Media temporary cache +stream_media/ +alac_cache/ +tmp/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfff78d --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# StreamGO ⚡ + +High-performance, ultra-low-memory media streaming and API service written in Go. + +## Features +- **Chi Router**: Lightweight, idiomatic HTTP router with standard middlewares (Logger, Recoverer, CORS, RealIP). +- **MongoDB Driver v2**: Modern connection pooling with ping health checks and graceful teardown. +- **Telegram MTProto (`gotd/td`)**: Pure Go MTProto client for media streaming and bot integration. +- **Minimal Footprint**: Operates in **~15 MB – 25 MB RAM** under idle/normal conditions. + +## Project Structure +``` +StreamGO/ +├── cmd/ +│ └── server/ +│ └── main.go # Application lifecycle and graceful shutdown +├── internal/ +│ ├── config/ +│ │ └── config.go # .env and environment variable loader +│ ├── database/ +│ │ └── mongodb.go # MongoDB connection pool and health checks +│ ├── server/ +│ │ └── router.go # Chi router, middlewares, and API endpoints +│ └── telegram/ +│ └── client.go # gotd/td MTProto service wrapper +├── .env.example # Template environment file +├── .gitignore +├── go.mod +└── README.md +``` + +## Getting Started + +### 1. Configure Environment +Copy `.env.example` to `.env` and fill in your credentials: +```bash +cp .env.example .env +``` + +### 2. Run Locally +```bash +go run ./cmd/server +``` + +### 3. Check Health +```bash +curl http://localhost:8000/health +``` + +Sample response: +```json +{ + "database": "healthy", + "service": "StreamGO", + "status": "ok", + "time": "2026-09-18T11:05:00Z", + "uptime": "5.123s" +} +``` + +### 4. Build Production Binary +```bash +go build -ldflags="-s -w" -o bin/server ./cmd/server +``` +Produces a single static binary (~18 MB on disk, uses ~20 MB RAM at runtime). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..02e0f91 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "streamgo/internal/config" + "streamgo/internal/database" + "streamgo/internal/server" + "streamgo/internal/telegram" +) + +func main() { + log.Println("==================================================") + log.Println(" StreamGO - High Performance Media API ") + log.Println("==================================================") + + // 1. Load Configuration + cfg := config.Load() + log.Printf("[main] loaded config: PORT=%s, DB=%s", cfg.Port, cfg.DatabaseName) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 2. Initialize MongoDB Connection + var dbClient *database.Client + if cfg.MongoURI != "" { + dbCtx, dbCancel := context.WithTimeout(ctx, 10*time.Second) + client, err := database.Connect(dbCtx, cfg.MongoURI, cfg.DatabaseName) + dbCancel() + if err != nil { + log.Printf("[main] WARNING: MongoDB connection failed: %v", err) + log.Println("[main] Continuing with MongoDB in disabled state...") + } else { + dbClient = client + defer func() { + closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer closeCancel() + _ = dbClient.Close(closeCtx) + }() + } + } + + // 3. Initialize Telegram Service (optional / background) + var tgService *telegram.Service + if cfg.ApiID > 0 && cfg.ApiHash != "" { + svc, err := telegram.New(cfg) + if err != nil { + log.Printf("[main] WARNING: Failed to initialize Telegram service: %v", err) + } else { + tgService = svc + log.Println("[main] Starting Telegram MTProto client in background...") + go func() { + if err := tgService.Start(ctx); err != nil { + log.Printf("[main] Telegram client start failed: %v", err) + } + }() + defer tgService.Stop() + } + } else { + log.Println("[main] Telegram credentials (API_ID/API_HASH) not configured; skipping Telegram init.") + } + + // 4. Initialize HTTP Server with Chi Router + srv := server.New(cfg, dbClient) + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.Port), + Handler: srv.Router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // 5. Start HTTP Listener in a separate goroutine + go func() { + log.Printf("[main] HTTP server listening on http://0.0.0.0:%s", cfg.Port) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("[main] HTTP server error: %v", err) + } + }() + + // 6. Graceful Shutdown on OS Signal (SIGINT, SIGTERM) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + + log.Printf("[main] received shutdown signal (%s), starting graceful shutdown...", sig) + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("[main] HTTP server forced shutdown error: %v", err) + } else { + log.Println("[main] HTTP server shutdown cleanly.") + } + + log.Println("[main] StreamGO stopped. Goodbye!") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cc65b72 --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module streamgo + +go 1.27.0 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-chi/cors v1.2.2 + github.com/gotd/td v0.162.0 + github.com/joho/godotenv v1.5.1 + go.mongodb.org/mongo-driver/v2 v2.9.1 +) + +require ( + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.8.0 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gotd/ige v0.3.0 // indirect + github.com/gotd/log v0.1.0 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ogen-go/ogen v1.23.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yuin/goldmark v1.8.5 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cae1e77 --- /dev/null +++ b/go.sum @@ -0,0 +1,154 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/gotd/ige v0.3.0 h1:4f6LEHWsVDLBG0bT9wWG2/9TZb5aWm265G8ZlTXmRRU= +github.com/gotd/ige v0.3.0/go.mod h1:FE9bTaQtvfArizAcZuI4sS6gXaEUBmixdUufVHoCKac= +github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU= +github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs= +github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ= +github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.162.0 h1:3pUzBsEbb0I5QquvfSkXihkVBXI+QTtdd/upijjUbkw= +github.com/gotd/td v0.162.0/go.mod h1:ZsGbErIos7XHF7HL0VlsjL6mmX4b1JpinHfDb7gOdvs= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo= +github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM= +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/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= +go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ad9fb0d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,82 @@ +package config + +import ( + "log" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config holds all configuration parameters for StreamGO. +type Config struct { + Port string + Debug bool + CorsOrigin string + MongoURI string + DatabaseName string + ApiID int + ApiHash string + BotToken string + SessionString string + ChannelID int64 + DumpChannelID int64 +} + +// Load reads configuration from .env file (if present) and environment variables. +func Load() *Config { + // Try loading .env; ignore error if not found (e.g., in container/production) + if err := godotenv.Load(); err != nil { + log.Println("[config] No .env file found, using system environment variables") + } else { + log.Println("[config] Loaded configuration from .env") + } + + return &Config{ + Port: getEnv("PORT", "8000"), + Debug: getEnvBool("DEBUG", false), + CorsOrigin: getEnv("CORS_ORIGIN", "*"), + MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), + DatabaseName: getEnv("DATABASE_NAME", "Stream"), + ApiID: getEnvInt("API_ID", 0), + ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), + BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), + SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), + ChannelID: getEnvInt64("CHANNEL_ID", 0), + DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), + } +} + +func getEnv(key, defaultVal string) string { + if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { + return strings.TrimSpace(val) + } + return defaultVal +} + +func getEnvBool(key string, defaultVal bool) bool { + if val, ok := os.LookupEnv(key); ok { + val = strings.ToLower(strings.TrimSpace(val)) + return val == "true" || val == "1" || val == "yes" + } + return defaultVal +} + +func getEnvInt(key string, defaultVal int) int { + if val, ok := os.LookupEnv(key); ok { + if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil { + return n + } + } + return defaultVal +} + +func getEnvInt64(key string, defaultVal int64) int64 { + if val, ok := os.LookupEnv(key); ok { + if n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil { + return n + } + } + return defaultVal +} diff --git a/internal/database/mongodb.go b/internal/database/mongodb.go new file mode 100644 index 0000000..9010d33 --- /dev/null +++ b/internal/database/mongodb.go @@ -0,0 +1,69 @@ +package database + +import ( + "context" + "fmt" + "log" + "time" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.mongodb.org/mongo-driver/v2/mongo/readpref" +) + +// Client wraps the mongo.Client and database instance. +type Client struct { + Client *mongo.Client + Database *mongo.Database +} + +// Connect establishes a connection pool to MongoDB and tests connectivity with Ping. +func Connect(ctx context.Context, uri, databaseName string) (*Client, error) { + if uri == "" { + return nil, fmt.Errorf("mongo uri is empty") + } + + opts := options.Client(). + ApplyURI(uri). + SetConnectTimeout(10 * time.Second). + SetServerSelectionTimeout(5 * time.Second). + SetMinPoolSize(2). + SetMaxPoolSize(50) + + client, err := mongo.Connect(opts) + if err != nil { + return nil, fmt.Errorf("failed to create mongo client: %w", err) + } + + // Verify connectivity with a quick ping + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := client.Ping(pingCtx, readpref.Primary()); err != nil { + _ = client.Disconnect(ctx) + return nil, fmt.Errorf("failed to ping mongodb at %s: %w", uri, err) + } + + log.Printf("[mongodb] connected successfully to database: %s", databaseName) + + return &Client{ + Client: client, + Database: client.Database(databaseName), + }, nil +} + +// Collection returns a handle to the specified collection. +func (c *Client) Collection(name string) *mongo.Collection { + return c.Database.Collection(name) +} + +// Ping checks if the database is responsive. +func (c *Client) Ping(ctx context.Context) error { + return c.Client.Ping(ctx, readpref.Primary()) +} + +// Close gracefully closes the connection pool. +func (c *Client) Close(ctx context.Context) error { + log.Println("[mongodb] disconnecting from database...") + return c.Client.Disconnect(ctx) +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000..7aa5d60 --- /dev/null +++ b/internal/server/router.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + + "streamgo/internal/config" + "streamgo/internal/database" +) + +// Server encapsulates the Chi router, config, and database handle. +type Server struct { + Router *chi.Mux + Config *config.Config + DB *database.Client + start time.Time +} + +// New creates and configures a new HTTP router with middlewares and core routes. +func New(cfg *config.Config, db *database.Client) *Server { + r := chi.NewRouter() + + s := &Server{ + Router: r, + Config: cfg, + DB: db, + start: time.Now(), + } + + // Global Middlewares + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(60 * time.Second)) + + // CORS Configuration + corsOrigins := []string{"*"} + if cfg.CorsOrigin != "" && cfg.CorsOrigin != "*" { + corsOrigins = []string{cfg.CorsOrigin} + } + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: corsOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Range"}, + ExposedHeaders: []string{"Link", "Content-Length", "Content-Range", "Accept-Ranges"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Core Routes + r.Get("/", s.handleIndex) + r.Get("/health", s.handleHealth) + + return s +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "service": "StreamGO", + "status": "running", + "version": "1.0.0", + }) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + dbStatus := "disabled" + if s.DB != nil { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.DB.Ping(ctx); err != nil { + dbStatus = "unhealthy: " + err.Error() + } else { + dbStatus = "healthy" + } + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "database": dbStatus, + "uptime": time.Since(s.start).String(), + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go new file mode 100644 index 0000000..218574a --- /dev/null +++ b/internal/telegram/client.go @@ -0,0 +1,134 @@ +package telegram + +import ( + "context" + "errors" + "fmt" + "log" + "sync" + "time" + + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/downloader" + "github.com/gotd/td/tg" + + "streamgo/internal/config" +) + +// Service encapsulates the gotd MTProto client lifecycle. +type Service struct { + Config *config.Config + Client *telegram.Client + API *tg.Client + Downloader *downloader.Downloader + + mu sync.RWMutex + ready bool + self *tg.User + stopCancel context.CancelFunc +} + +// New creates an unstarted Telegram service instance. +func New(cfg *config.Config) (*Service, error) { + if cfg.ApiID <= 0 || cfg.ApiHash == "" { + return nil, errors.New("API_ID and API_HASH are required to initialize Telegram client") + } + + opts := telegram.Options{ + NoUpdates: false, + } + + client := telegram.NewClient(cfg.ApiID, cfg.ApiHash, opts) + + return &Service{ + Config: cfg, + Client: client, + API: client.API(), + Downloader: downloader.NewDownloader(), + }, nil +} + +// Start launches the Telegram client loop in the background and authenticates. +func (s *Service) Start(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + s.stopCancel = cancel + + readyChan := make(chan struct{}) + errChan := make(chan error, 1) + + go func() { + err := s.Client.Run(ctx, func(ctx context.Context) error { + // Handle authentication + authStatus, err := s.Client.Auth().Status(ctx) + if err != nil { + return fmt.Errorf("failed to check auth status: %w", err) + } + + if !authStatus.Authorized { + if s.Config.BotToken != "" { + log.Println("[telegram] authorizing with BOT_TOKEN...") + if _, err := s.Client.Auth().Bot(ctx, s.Config.BotToken); err != nil { + return fmt.Errorf("bot authentication failed: %w", err) + } + log.Println("[telegram] bot authentication successful!") + } else { + log.Println("[telegram] client is not authorized and no BOT_TOKEN provided") + } + } + + // Get authenticated user info + self, err := s.Client.Self(ctx) + if err == nil && self != nil { + s.mu.Lock() + s.self = self + s.ready = true + s.mu.Unlock() + log.Printf("[telegram] connected as: %s (ID: %d, Bot: %v)", self.FirstName, self.ID, self.Bot) + } + + close(readyChan) + + // Keep client running until context cancellation + <-ctx.Done() + return ctx.Err() + }) + + if err != nil && !errors.Is(err, context.Canceled) { + errChan <- err + } + }() + + // Wait for connection or timeout + select { + case <-readyChan: + return nil + case err := <-errChan: + return err + case <-time.After(15 * time.Second): + return fmt.Errorf("timeout waiting for telegram connection") + case <-ctx.Done(): + return ctx.Err() + } +} + +// IsReady returns true if the client is connected and authorized. +func (s *Service) IsReady() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ready +} + +// Self returns the authenticated Telegram user/bot profile. +func (s *Service) Self() *tg.User { + s.mu.RLock() + defer s.mu.RUnlock() + return s.self +} + +// Stop terminates the Telegram background loop. +func (s *Service) Stop() { + if s.stopCancel != nil { + log.Println("[telegram] stopping client...") + s.stopCancel() + } +} From 73f3910bbb3e327d1589f3371208c8d05f24036e Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 16:49:18 +0530 Subject: [PATCH 02/39] feat: add structured colored logger and API_LOGS toggle to silence HTTP request spam --- .env.example | 1 + cmd/server/main.go | 36 +++--- internal/config/config.go | 39 ++++--- internal/database/mongodb.go | 9 +- internal/logger/logger.go | 213 +++++++++++++++++++++++++++++++++++ internal/server/router.go | 4 +- internal/telegram/client.go | 14 ++- 7 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 internal/logger/logger.go diff --git a/.env.example b/.env.example index abee8de..64dbf5f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Server Configuration PORT=8000 DEBUG=false +API_LOGS=false CORS_ORIGIN=* # MongoDB Configuration diff --git a/cmd/server/main.go b/cmd/server/main.go index 02e0f91..476e8cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "net/http" "os" "os/signal" @@ -13,18 +12,21 @@ import ( "streamgo/internal/config" "streamgo/internal/database" + "streamgo/internal/logger" "streamgo/internal/server" "streamgo/internal/telegram" ) +var log = logger.New("main") + func main() { - log.Println("==================================================") - log.Println(" StreamGO - High Performance Media API ") - log.Println("==================================================") + fmt.Println("==================================================") + fmt.Println(" StreamGO - High Performance Media API ") + fmt.Println("==================================================") // 1. Load Configuration cfg := config.Load() - log.Printf("[main] loaded config: PORT=%s, DB=%s", cfg.Port, cfg.DatabaseName) + log.Infof("loaded config: PORT=%s, DB=%s, API_LOGS=%v", cfg.Port, cfg.DatabaseName, cfg.APILogs) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -36,8 +38,8 @@ func main() { client, err := database.Connect(dbCtx, cfg.MongoURI, cfg.DatabaseName) dbCancel() if err != nil { - log.Printf("[main] WARNING: MongoDB connection failed: %v", err) - log.Println("[main] Continuing with MongoDB in disabled state...") + log.Warnf("MongoDB connection failed: %v", err) + log.Warn("Continuing with MongoDB in disabled state...") } else { dbClient = client defer func() { @@ -53,19 +55,19 @@ func main() { if cfg.ApiID > 0 && cfg.ApiHash != "" { svc, err := telegram.New(cfg) if err != nil { - log.Printf("[main] WARNING: Failed to initialize Telegram service: %v", err) + log.Warnf("Failed to initialize Telegram service: %v", err) } else { tgService = svc - log.Println("[main] Starting Telegram MTProto client in background...") + log.Info("Starting Telegram MTProto client in background...") go func() { if err := tgService.Start(ctx); err != nil { - log.Printf("[main] Telegram client start failed: %v", err) + log.Errorf("Telegram client start failed: %v", err) } }() defer tgService.Stop() } } else { - log.Println("[main] Telegram credentials (API_ID/API_HASH) not configured; skipping Telegram init.") + log.Info("Telegram credentials (API_ID/API_HASH) not configured; skipping Telegram init.") } // 4. Initialize HTTP Server with Chi Router @@ -80,9 +82,9 @@ func main() { // 5. Start HTTP Listener in a separate goroutine go func() { - log.Printf("[main] HTTP server listening on http://0.0.0.0:%s", cfg.Port) + log.Infof("HTTP server listening on http://0.0.0.0:%s", cfg.Port) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("[main] HTTP server error: %v", err) + log.Fatalf("HTTP server error: %v", err) } }() @@ -91,16 +93,16 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) sig := <-quit - log.Printf("[main] received shutdown signal (%s), starting graceful shutdown...", sig) + log.Infof("received shutdown signal (%s), starting graceful shutdown...", sig) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { - log.Printf("[main] HTTP server forced shutdown error: %v", err) + log.Errorf("HTTP server forced shutdown error: %v", err) } else { - log.Println("[main] HTTP server shutdown cleanly.") + log.Info("HTTP server shutdown cleanly.") } - log.Println("[main] StreamGO stopped. Goodbye!") + log.Info("StreamGO stopped. Goodbye!") } diff --git a/internal/config/config.go b/internal/config/config.go index ad9fb0d..a4d275f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,26 +1,29 @@ package config import ( - "log" "os" "strconv" "strings" "github.com/joho/godotenv" + "streamgo/internal/logger" ) +var log = logger.New("config") + // Config holds all configuration parameters for StreamGO. type Config struct { - Port string - Debug bool - CorsOrigin string - MongoURI string - DatabaseName string - ApiID int - ApiHash string - BotToken string + Port string + Debug bool + APILogs bool + CorsOrigin string + MongoURI string + DatabaseName string + ApiID int + ApiHash string + BotToken string SessionString string - ChannelID int64 + ChannelID int64 DumpChannelID int64 } @@ -28,16 +31,24 @@ type Config struct { func Load() *Config { // Try loading .env; ignore error if not found (e.g., in container/production) if err := godotenv.Load(); err != nil { - log.Println("[config] No .env file found, using system environment variables") + log.Info("No .env file found, using system environment variables") } else { - log.Println("[config] Loaded configuration from .env") + log.Info("Loaded configuration from .env") } + debug := getEnvBool("DEBUG", false) + logger.SetDebug(debug) + + mongoURI := strings.TrimSpace(getEnv("MONGO_URI", "mongodb://localhost:27017")) + // Sanitize any accidental leading equals sign (e.g. MONGO_URI==...) + mongoURI = strings.TrimPrefix(mongoURI, "=") + return &Config{ Port: getEnv("PORT", "8000"), - Debug: getEnvBool("DEBUG", false), + Debug: debug, + APILogs: getEnvBool("API_LOGS", getEnvBool("HTTP_LOGS", false)), CorsOrigin: getEnv("CORS_ORIGIN", "*"), - MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), + MongoURI: mongoURI, DatabaseName: getEnv("DATABASE_NAME", "Stream"), ApiID: getEnvInt("API_ID", 0), ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), diff --git a/internal/database/mongodb.go b/internal/database/mongodb.go index 9010d33..28c016c 100644 --- a/internal/database/mongodb.go +++ b/internal/database/mongodb.go @@ -3,14 +3,17 @@ package database import ( "context" "fmt" - "log" "time" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/readpref" + + "streamgo/internal/logger" ) +var log = logger.New("mongodb") + // Client wraps the mongo.Client and database instance. type Client struct { Client *mongo.Client @@ -44,7 +47,7 @@ func Connect(ctx context.Context, uri, databaseName string) (*Client, error) { return nil, fmt.Errorf("failed to ping mongodb at %s: %w", uri, err) } - log.Printf("[mongodb] connected successfully to database: %s", databaseName) + log.Infof("connected successfully to database: %s", databaseName) return &Client{ Client: client, @@ -64,6 +67,6 @@ func (c *Client) Ping(ctx context.Context) error { // Close gracefully closes the connection pool. func (c *Client) Close(ctx context.Context) error { - log.Println("[mongodb] disconnecting from database...") + log.Info("disconnecting from database...") return c.Client.Disconnect(ctx) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..a795506 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,213 @@ +package logger + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// Level defines log severity. +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +var levelNames = map[Level]string{ + LevelDebug: "DEBUG", + LevelInfo: "INFO", + LevelWarn: "WARN", + LevelError: "ERROR", +} + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorMagenta = "\033[35m" + colorCyan = "\033[36m" + colorGray = "\033[90m" +) + +var levelColors = map[Level]string{ + LevelDebug: colorMagenta, + LevelInfo: colorCyan, + LevelWarn: colorYellow, + LevelError: colorRed, +} + +var ( + currentLevel = LevelInfo + useColors = true + outMu sync.Mutex + outWriter io.Writer = os.Stdout + defaultLog = New("app") +) + +func init() { + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + useColors = false + } + if strings.ToLower(os.Getenv("DEBUG")) == "true" || os.Getenv("DEBUG") == "1" { + currentLevel = LevelDebug + } +} + +// SetDebug toggles debug level logging. +func SetDebug(enabled bool) { + if enabled { + currentLevel = LevelDebug + } else { + currentLevel = LevelInfo + } +} + +// Logger provides leveled, prefixed logging. +type Logger struct { + prefix string +} + +// New creates a new Logger with the specified module prefix. +func New(prefix string) *Logger { + return &Logger{prefix: prefix} +} + +func (l *Logger) log(lvl Level, msg string) { + if lvl < currentLevel { + return + } + + outMu.Lock() + defer outMu.Unlock() + + timestamp := time.Now().Format("2006/01/02 15:04:05") + lvlName := levelNames[lvl] + + if useColors { + color := levelColors[lvl] + fmt.Fprintf(outWriter, "%s%s%s %s[%-5s]%s %s[%s]%s %s\n", + colorGray, timestamp, colorReset, + color, lvlName, colorReset, + colorBlue, l.prefix, colorReset, + msg, + ) + } else { + fmt.Fprintf(outWriter, "%s [%-5s] [%s] %s\n", + timestamp, + lvlName, + l.prefix, + msg, + ) + } +} + +func (l *Logger) Debug(args ...interface{}) { + l.log(LevelDebug, fmt.Sprint(args...)) +} + +func (l *Logger) Debugf(format string, args ...interface{}) { + l.log(LevelDebug, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Info(args ...interface{}) { + l.log(LevelInfo, fmt.Sprint(args...)) +} + +func (l *Logger) Infof(format string, args ...interface{}) { + l.log(LevelInfo, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Warn(args ...interface{}) { + l.log(LevelWarn, fmt.Sprint(args...)) +} + +func (l *Logger) Warnf(format string, args ...interface{}) { + l.log(LevelWarn, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Error(args ...interface{}) { + l.log(LevelError, fmt.Sprint(args...)) +} + +func (l *Logger) Errorf(format string, args ...interface{}) { + l.log(LevelError, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Fatal(args ...interface{}) { + l.log(LevelError, fmt.Sprint(args...)) + os.Exit(1) +} + +func (l *Logger) Fatalf(format string, args ...interface{}) { + l.log(LevelError, fmt.Sprintf(format, args...)) + os.Exit(1) +} + +// Package-level global logging convenience functions +func Debug(args ...interface{}) { defaultLog.Debug(args...) } +func Debugf(format string, args ...interface{}) { defaultLog.Debugf(format, args...) } +func Info(args ...interface{}) { defaultLog.Info(args...) } +func Infof(format string, args ...interface{}) { defaultLog.Infof(format, args...) } +func Warn(args ...interface{}) { defaultLog.Warn(args...) } +func Warnf(format string, args ...interface{}) { defaultLog.Warnf(format, args...) } +func Error(args ...interface{}) { defaultLog.Error(args...) } +func Errorf(format string, args ...interface{}) { defaultLog.Errorf(format, args...) } +func Fatal(args ...interface{}) { defaultLog.Fatal(args...) } +func Fatalf(format string, args ...interface{}) { defaultLog.Fatalf(format, args...) } + +// HTTPMiddleware creates an HTTP request logger that can be enabled or disabled. +// When enabled is false, it passes through requests completely silent. +func HTTPMiddleware(enabled bool) func(next http.Handler) http.Handler { + httpLog := New("http") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !enabled { + next.ServeHTTP(w, r) + return + } + + // Wrap response writer to capture status and bytes written + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + + next.ServeHTTP(ww, r) + + duration := time.Since(start) + status := ww.Status() + if status == 0 { + status = http.StatusOK + } + + // Clean, formatted request log + msg := fmt.Sprintf("%s %s -> %d (%s, %dB) from %s", + r.Method, + r.URL.Path, + status, + duration.Round(time.Millisecond), + ww.BytesWritten(), + r.RemoteAddr, + ) + + if status >= 500 { + httpLog.Errorf(msg) + } else if status >= 400 { + httpLog.Warnf(msg) + } else { + httpLog.Infof(msg) + } + }) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 7aa5d60..eecf860 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -12,6 +12,7 @@ import ( "streamgo/internal/config" "streamgo/internal/database" + "streamgo/internal/logger" ) // Server encapsulates the Chi router, config, and database handle. @@ -36,7 +37,8 @@ func New(cfg *config.Config, db *database.Client) *Server { // Global Middlewares r.Use(middleware.RequestID) r.Use(middleware.RealIP) - r.Use(middleware.Logger) + // Toggleable HTTP request logger: completely silent when APILogs is false + r.Use(logger.HTTPMiddleware(cfg.APILogs)) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(60 * time.Second)) diff --git a/internal/telegram/client.go b/internal/telegram/client.go index 218574a..626bbed 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "sync" "time" @@ -13,8 +12,11 @@ import ( "github.com/gotd/td/tg" "streamgo/internal/config" + "streamgo/internal/logger" ) +var log = logger.New("telegram") + // Service encapsulates the gotd MTProto client lifecycle. type Service struct { Config *config.Config @@ -66,13 +68,13 @@ func (s *Service) Start(ctx context.Context) error { if !authStatus.Authorized { if s.Config.BotToken != "" { - log.Println("[telegram] authorizing with BOT_TOKEN...") + log.Info("authorizing with BOT_TOKEN...") if _, err := s.Client.Auth().Bot(ctx, s.Config.BotToken); err != nil { return fmt.Errorf("bot authentication failed: %w", err) } - log.Println("[telegram] bot authentication successful!") + log.Info("bot authentication successful!") } else { - log.Println("[telegram] client is not authorized and no BOT_TOKEN provided") + log.Warn("client is not authorized and no BOT_TOKEN provided") } } @@ -83,7 +85,7 @@ func (s *Service) Start(ctx context.Context) error { s.self = self s.ready = true s.mu.Unlock() - log.Printf("[telegram] connected as: %s (ID: %d, Bot: %v)", self.FirstName, self.ID, self.Bot) + log.Infof("connected as: %s (ID: %d, Bot: %v)", self.FirstName, self.ID, self.Bot) } close(readyChan) @@ -128,7 +130,7 @@ func (s *Service) Self() *tg.User { // Stop terminates the Telegram background loop. func (s *Service) Stop() { if s.stopCancel != nil { - log.Println("[telegram] stopping client...") + log.Info("stopping client...") s.stopCancel() } } From d09d6d245792e2d3c1c5bb78b570c8123052cf5e Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 16:54:30 +0530 Subject: [PATCH 03/39] feat: add Swagger UI (/docs), ReDoc (/redoc), and OpenAPI 3.0 spec (/openapi.json) --- internal/server/docs.go | 144 ++++++++++++++++++++++++++++++++++++++ internal/server/router.go | 9 +++ 2 files changed, 153 insertions(+) create mode 100644 internal/server/docs.go diff --git a/internal/server/docs.go b/internal/server/docs.go new file mode 100644 index 0000000..053fc96 --- /dev/null +++ b/internal/server/docs.go @@ -0,0 +1,144 @@ +package server + +import ( + "net/http" +) + +const openAPISpecJSON = `{ + "openapi": "3.0.3", + "info": { + "title": "StreamGO API", + "description": "High-Performance Media Streaming and Telegram Bot Backend written in Go", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/", + "description": "Current Server" + } + ], + "paths": { + "/": { + "get": { + "tags": ["System"], + "summary": "Root Service Info", + "description": "Returns basic service metadata, status, and running version.", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { "type": "string", "example": "StreamGO" }, + "status": { "type": "string", "example": "running" }, + "version": { "type": "string", "example": "1.0.0" } + } + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": ["System"], + "summary": "Health Check", + "description": "Returns server uptime, current time, and real-time MongoDB connectivity status.", + "responses": { + "200": { + "description": "Health status response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "string", "example": "ok" }, + "database": { "type": "string", "example": "healthy" }, + "uptime": { "type": "string", "example": "1h23m45s" }, + "time": { "type": "string", "example": "2026-09-18T11:22:33Z" } + } + } + } + } + } + } + } + } + } +}` + +const swaggerUIHTML = ` + + + + StreamGO - Swagger UI + + + + + +
+ + + + +` + +const redocHTML = ` + + + StreamGO - ReDoc + + + + + + + + + +` + +func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(openAPISpecJSON)) +} + +func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(swaggerUIHTML)) +} + +func (s *Server) handleRedoc(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(redocHTML)) +} diff --git a/internal/server/router.go b/internal/server/router.go index eecf860..fd5a7fa 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -61,6 +61,15 @@ func New(cfg *config.Config, db *database.Client) *Server { r.Get("/", s.handleIndex) r.Get("/health", s.handleHealth) + // API Documentation Routes (Swagger UI & ReDoc) + r.Get("/docs", s.handleDocs) + r.Get("/docs/*", s.handleDocs) + r.Head("/docs", s.handleDocs) + r.Get("/redoc", s.handleRedoc) + r.Head("/redoc", s.handleRedoc) + r.Get("/openapi.json", s.handleOpenAPISpec) + r.Head("/openapi.json", s.handleOpenAPISpec) + return s } From e0acbc1f1d2eecd9b44a820f5f50562ddd83a3ee Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 17:08:51 +0530 Subject: [PATCH 04/39] feat: modular port of tracks, topics, browse, search, and streaming engine --- cmd/server/main.go | 2 +- internal/api/handlers/stream.go | 41 ++++ internal/api/handlers/topics.go | 82 +++++++ internal/api/handlers/tracks.go | 110 +++++++++ internal/api/response.go | 55 +++++ internal/logger/logger.go | 6 +- internal/models/track.go | 154 +++++++++++++ internal/repository/track_repo.go | 279 +++++++++++++++++++++++ internal/server/docs.go | 155 ++++++++++--- internal/server/router.go | 42 +++- internal/services/stream_service.go | 246 ++++++++++++++++++++ internal/services/stream_service_test.go | 91 ++++++++ internal/services/track_service.go | 129 +++++++++++ 13 files changed, 1353 insertions(+), 39 deletions(-) create mode 100644 internal/api/handlers/stream.go create mode 100644 internal/api/handlers/topics.go create mode 100644 internal/api/handlers/tracks.go create mode 100644 internal/api/response.go create mode 100644 internal/models/track.go create mode 100644 internal/repository/track_repo.go create mode 100644 internal/services/stream_service.go create mode 100644 internal/services/stream_service_test.go create mode 100644 internal/services/track_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 476e8cd..b118e55 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -71,7 +71,7 @@ func main() { } // 4. Initialize HTTP Server with Chi Router - srv := server.New(cfg, dbClient) + srv := server.New(cfg, dbClient, tgService) httpServer := &http.Server{ Addr: fmt.Sprintf(":%s", cfg.Port), Handler: srv.Router, diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go new file mode 100644 index 0000000..339bdc2 --- /dev/null +++ b/internal/api/handlers/stream.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// StreamHandler handles audio streaming endpoints. +type StreamHandler struct { + streamService *services.StreamService +} + +// NewStreamHandler creates a new StreamHandler. +func NewStreamHandler(svc *services.StreamService) *StreamHandler { + return &StreamHandler{streamService: svc} +} + +// Routes mounts the streaming endpoints. +func (h *StreamHandler) Routes(r chi.Router) { + r.Get("/tracks/{id}/stream", h.Stream) + r.Head("/tracks/{id}/stream", h.Stream) + r.Get("/stream/{id}", h.Stream) + r.Head("/stream/{id}", h.Stream) +} + +// Stream handles GET and HEAD /tracks/{id}/stream with HTTP Range support. +func (h *StreamHandler) Stream(w http.ResponseWriter, r *http.Request) { + trackID := chi.URLParam(r, "id") + trackID = strings.TrimSpace(trackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + h.streamService.StreamTrack(w, r, trackID) +} diff --git a/internal/api/handlers/topics.go b/internal/api/handlers/topics.go new file mode 100644 index 0000000..988247b --- /dev/null +++ b/internal/api/handlers/topics.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// TopicHandler handles routes related to topics and source channels. +type TopicHandler struct { + trackService *services.TrackService +} + +// NewTopicHandler creates a new TopicHandler. +func NewTopicHandler(svc *services.TrackService) *TopicHandler { + return &TopicHandler{trackService: svc} +} + +// Routes mounts topic and channel endpoints. +func (h *TopicHandler) Routes(r chi.Router) { + r.Get("/topics", h.ListTopics) + r.Get("/topics/{name}/tracks", h.TopicTracks) + r.Get("/channelids", h.ChannelIDs) +} + +// ListTopics handles GET /topics. +func (h *TopicHandler) ListTopics(w http.ResponseWriter, r *http.Request) { + limit := api.ParseQueryInt(r, "limit", 100) + + resp, err := h.trackService.GetTopics(r.Context(), limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// TopicTracks handles GET /topics/{name}/tracks with pagination. +func (h *TopicHandler) TopicTracks(w http.ResponseWriter, r *http.Request) { + rawName := chi.URLParam(r, "name") + topicName, err := url.PathUnescape(rawName) + if err != nil || strings.TrimSpace(topicName) == "" { + topicName = rawName + } + topicName = strings.TrimSpace(topicName) + if topicName == "" { + api.RespondError(w, http.StatusBadRequest, "topic name is required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 20) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + resp, err := h.trackService.Browse(r.Context(), page, perPage, "", topicName, channelID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// ChannelIDs handles GET /channelids. +func (h *TopicHandler) ChannelIDs(w http.ResponseWriter, r *http.Request) { + resp, err := h.trackService.GetChannelIDs(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/tracks.go b/internal/api/handlers/tracks.go new file mode 100644 index 0000000..949ac35 --- /dev/null +++ b/internal/api/handlers/tracks.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// TrackHandler handles HTTP routes for track operations. +type TrackHandler struct { + trackService *services.TrackService +} + +// NewTrackHandler creates a new TrackHandler. +func NewTrackHandler(svc *services.TrackService) *TrackHandler { + return &TrackHandler{trackService: svc} +} + +// Routes mounts track endpoints on the given Chi router. +func (h *TrackHandler) Routes(r chi.Router) { + r.Get("/tracks", h.List) + r.Get("/browse", h.List) + r.Get("/search", h.Search) + r.Get("/tracks/random", h.Random) + r.Get("/tracks/{id}", h.GetByID) +} + +// List handles GET /tracks and GET /browse with pagination, sorting, and filters. +func (h *TrackHandler) List(w http.ResponseWriter, r *http.Request) { + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 20) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + sortField := strings.TrimSpace(r.URL.Query().Get("sort")) + topicName := strings.TrimSpace(r.URL.Query().Get("topic")) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + resp, err := h.trackService.Browse(r.Context(), page, perPage, sortField, topicName, channelID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /tracks/{id}. +func (h *TrackHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + track, err := h.trackService.GetTrack(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if track == nil { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + + api.RespondJSON(w, http.StatusOK, track) +} + +// Search handles GET /search with text queries. +func (h *TrackHandler) Search(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + query = strings.TrimSpace(r.URL.Query().Get("query")) + } + limit := api.ParseQueryInt(r, "limit", 50) + + items, err := h.trackService.Search(r.Context(), query, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]interface{}{ + "query": query, + "total": len(items), + "items": items, + }) +} + +// Random handles GET /tracks/random. +func (h *TrackHandler) Random(w http.ResponseWriter, r *http.Request) { + limit := api.ParseQueryInt(r, "limit", 20) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + items, err := h.trackService.GetRandom(r.Context(), limit, channelID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]interface{}{ + "total": len(items), + "items": items, + }) +} diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..91d5e9f --- /dev/null +++ b/internal/api/response.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" +) + +// ErrorResponse defines standard API error envelope. +type ErrorResponse struct { + OK bool `json:"ok"` + Detail string `json:"detail"` + Status int `json:"status"` +} + +// RespondJSON writes a JSON payload with standard status code. +func RespondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +// RespondError writes a standardized JSON error message. +func RespondError(w http.ResponseWriter, status int, message string) { + RespondJSON(w, status, ErrorResponse{ + OK: false, + Detail: message, + Status: status, + }) +} + +// ParseQueryInt parses an integer query parameter with a fallback default. +func ParseQueryInt(r *http.Request, key string, defaultVal int) int { + val := strings.TrimSpace(r.URL.Query().Get(key)) + if val == "" { + return defaultVal + } + if n, err := strconv.Atoi(val); err == nil { + return n + } + return defaultVal +} + +// ParseQueryInt64 parses an int64 query parameter with a fallback default. +func ParseQueryInt64(r *http.Request, key string, defaultVal int64) int64 { + val := strings.TrimSpace(r.URL.Query().Get(key)) + if val == "" { + return defaultVal + } + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return n + } + return defaultVal +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index a795506..9b8b9dc 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -202,11 +202,11 @@ func HTTPMiddleware(enabled bool) func(next http.Handler) http.Handler { ) if status >= 500 { - httpLog.Errorf(msg) + httpLog.Error(msg) } else if status >= 400 { - httpLog.Warnf(msg) + httpLog.Warn(msg) } else { - httpLog.Infof(msg) + httpLog.Info(msg) } }) } diff --git a/internal/models/track.go b/internal/models/track.go new file mode 100644 index 0000000..a89d6aa --- /dev/null +++ b/internal/models/track.go @@ -0,0 +1,154 @@ +package models + +// AudioMeta represents metadata extracted from the audio stream or tags. +type AudioMeta struct { + Title string `bson:"title,omitempty" json:"title"` + Artist string `bson:"artist,omitempty" json:"artist"` + Performer string `bson:"performer,omitempty" json:"performer,omitempty"` + Album string `bson:"album,omitempty" json:"album,omitempty"` + AlbumID string `bson:"album_id,omitempty" json:"album_id,omitempty"` + Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` + DurationSec float64 `bson:"duration_sec,omitempty" json:"duration_sec"` + Type string `bson:"type,omitempty" json:"type,omitempty"` + SamplingRateHz int `bson:"sampling_rate_hz,omitempty" json:"sampling_rate_hz,omitempty"` + Bitrate int `bson:"bitrate,omitempty" json:"bitrate,omitempty"` + MimeType string `bson:"mime_type,omitempty" json:"mime_type,omitempty"` + FileSize int64 `bson:"file_size,omitempty" json:"file_size,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` +} + +// TelegramMeta stores Telegram file references and chat mapping. +type TelegramMeta struct { + FileID string `bson:"file_id,omitempty" json:"file_id"` + FileUniqueID string `bson:"file_unique_id,omitempty" json:"file_unique_id,omitempty"` + FileName string `bson:"file_name,omitempty" json:"file_name,omitempty"` + FileSize int64 `bson:"file_size,omitempty" json:"file_size,omitempty"` + MimeType string `bson:"mime_type,omitempty" json:"mime_type,omitempty"` + FileIDs map[string]string `bson:"file_ids,omitempty" json:"file_ids,omitempty"` +} + +// SpotifyMeta stores resolved Spotify match data and cover URLs. +type SpotifyMeta struct { + URL string `bson:"url,omitempty" json:"url,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + TrackSpotifyID string `bson:"track_spotify_id,omitempty" json:"track_spotify_id,omitempty"` +} + +// Track represents the full track document stored in the audioTracks MongoDB collection. +type Track struct { + ID string `bson:"_id" json:"_id"` + Audio AudioMeta `bson:"audio" json:"audio"` + Telegram TelegramMeta `bson:"telegram" json:"telegram"` + Spotify SpotifyMeta `bson:"spotify,omitempty" json:"spotify,omitempty"` + SourceChatID int64 `bson:"source_chat_id,omitempty" json:"source_chat_id,omitempty"` + SourceMessageID int64 `bson:"source_message_id,omitempty" json:"source_message_id,omitempty"` + CacheChatID int64 `bson:"cache_chat_id,omitempty" json:"cache_chat_id,omitempty"` + CacheMessageID int64 `bson:"cache_message_id,omitempty" json:"cache_message_id,omitempty"` + TopicID int64 `bson:"topic_id,omitempty" json:"topic_id,omitempty"` + TopicName string `bson:"topic_name,omitempty" json:"topic_name,omitempty"` + PlayCount int64 `bson:"play_count,omitempty" json:"play_count"` + LikesCount int64 `bson:"likes_count,omitempty" json:"likes_count"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` + Deleted bool `bson:"deleted,omitempty" json:"deleted"` + Liked bool `bson:"-" json:"liked"` +} + +// EffectiveCoverURL returns the best available cover art URL. +func (t *Track) EffectiveCoverURL() string { + if t.Spotify.CoverURL != "" { + return t.Spotify.CoverURL + } + return t.Audio.CoverURL +} + +// EffectiveArtist returns the best artist/performer string. +func (t *Track) EffectiveArtist() string { + if t.Audio.Artist != "" { + return t.Audio.Artist + } + return t.Audio.Performer +} + +// BrowseItem represents a flattened track item optimized for list/feed UI views. +type BrowseItem struct { + ID string `json:"_id"` + SourceChatID int64 `json:"source_chat_id,omitempty"` + SourceMessageID int64 `json:"source_message_id,omitempty"` + TopicID int64 `json:"topic_id,omitempty"` + TopicName string `json:"topic_name,omitempty"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album,omitempty"` + AlbumID string `json:"album_id,omitempty"` + DurationSec float64 `json:"duration_sec"` + Type string `json:"type,omitempty"` + SamplingRateHz int `json:"sampling_rate_hz,omitempty"` + SpotifyURL string `json:"spotify_url,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Titles []string `json:"titles,omitempty"` + CreatedAt float64 `json:"created_at"` + UpdatedAt float64 `json:"updated_at"` + Liked bool `json:"liked"` +} + +// ToBrowseItem converts a full Track entity into a lightweight BrowseItem. +func (t *Track) ToBrowseItem() *BrowseItem { + return &BrowseItem{ + ID: t.ID, + SourceChatID: t.SourceChatID, + SourceMessageID: t.SourceMessageID, + TopicID: t.TopicID, + TopicName: t.TopicName, + Title: t.Audio.Title, + Artist: t.EffectiveArtist(), + Album: t.Audio.Album, + AlbumID: t.Audio.AlbumID, + DurationSec: t.Audio.DurationSec, + Type: t.Audio.Type, + SamplingRateHz: t.Audio.SamplingRateHz, + SpotifyURL: t.Spotify.URL, + CoverURL: t.EffectiveCoverURL(), + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + Liked: t.Liked, + } +} + +// BrowseResponse represents a paginated response of BrowseItem objects. +type BrowseResponse struct { + Items []*BrowseItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` +} + +// TopicItem represents an aggregated topic with track counts and cover image. +type TopicItem struct { + TopicID int64 `json:"topic_id"` + TopicName string `json:"topic_name"` + TracksCount int64 `json:"tracks_count"` + CoverURL string `json:"cover_url,omitempty"` +} + +// TopicsResponse is the response payload for GET /topics. +type TopicsResponse struct { + OK bool `json:"ok"` + Total int `json:"total"` + Items []*TopicItem `json:"items"` +} + +// ChannelItem represents an indexed Telegram source channel. +type ChannelItem struct { + ID int64 `json:"id"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + Type string `json:"type,omitempty"` +} + +// ChannelIDsResponse is the response payload for GET /channelids. +type ChannelIDsResponse struct { + OK bool `json:"ok"` + Items []ChannelItem `json:"items"` +} diff --git a/internal/repository/track_repo.go b/internal/repository/track_repo.go new file mode 100644 index 0000000..4ae0bb8 --- /dev/null +++ b/internal/repository/track_repo.go @@ -0,0 +1,279 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// TrackRepository defines the data access contract for tracks and related metadata. +type TrackRepository interface { + GetByID(ctx context.Context, id string) (*models.Track, error) + List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) + Search(ctx context.Context, query string, limit int) ([]*models.Track, error) + Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) + GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) + GetChannelIDs(ctx context.Context) ([]int64, error) + IncrementPlayCount(ctx context.Context, id string) error +} + +type mongoTrackRepository struct { + db *database.Client + col *mongo.Collection +} + +// NewTrackRepository creates a MongoDB-backed TrackRepository. +func NewTrackRepository(db *database.Client) TrackRepository { + return &mongoTrackRepository{ + db: db, + col: db.Collection("audioTracks"), + } +} + +func (r *mongoTrackRepository) GetByID(ctx context.Context, id string) (*models.Track, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, errors.New("track id is required") + } + + filter := bson.M{ + "_id": id, + "deleted": bson.M{"$ne": true}, + } + + var track models.Track + err := r.col.FindOne(ctx, filter).Decode(&track) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch track %s: %w", id, err) + } + + return &track, nil +} + +func (r *mongoTrackRepository) List( + ctx context.Context, + page, perPage int, + sortField, topicName string, + channelID int64, +) ([]*models.Track, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + filter := bson.M{"deleted": bson.M{"$ne": true}} + + if topicName != "" { + filter["topic_name"] = topicName + } + if channelID != 0 { + filter["source_chat_id"] = channelID + } + + total, err := r.col.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("failed to count tracks: %w", err) + } + + // Default sort by updated_at descending + sortDoc := bson.D{{Key: "updated_at", Value: -1}} + if sortField == "play_count" { + sortDoc = bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}} + } else if sortField == "created_at" { + sortDoc = bson.D{{Key: "created_at", Value: -1}} + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(sortDoc). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("failed to list tracks: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, 0, fmt.Errorf("failed to decode tracks: %w", err) + } + + return tracks, total, nil +} + +func (r *mongoTrackRepository) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) { + query = strings.TrimSpace(query) + if query == "" { + return []*models.Track{}, nil + } + if limit <= 0 || limit > 100 { + limit = 50 + } + + escaped := regexp.QuoteMeta(query) + regexPattern := bson.M{"$regex": escaped, "$options": "i"} + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.title": regexPattern}, + {"audio.artist": regexPattern}, + {"audio.performer": regexPattern}, + {"audio.album": regexPattern}, + }, + } + + opts := options.Find(). + SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetLimit(int64(limit)) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("search failed: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, fmt.Errorf("failed to decode search results: %w", err) + } + + return tracks, nil +} + +func (r *mongoTrackRepository) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + match := bson.M{"deleted": bson.M{"$ne": true}} + if channelID != 0 { + match["source_chat_id"] = channelID + } + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: match}}, + bson.D{{Key: "$sample", Value: bson.M{"size": limit}}}, + } + + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("random aggregation failed: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, fmt.Errorf("failed to decode random tracks: %w", err) + } + + return tracks, nil +} + +func (r *mongoTrackRepository) GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: bson.M{ + "deleted": bson.M{"$ne": true}, + "topic_name": bson.M{"$exists": true, "$nin": []interface{}{"", nil}}, + }}}, + bson.D{{Key: "$group", Value: bson.M{ + "_id": "$topic_name", + "topic_id": bson.M{"$first": "$topic_id"}, + "tracks_count": bson.M{"$sum": 1}, + "cover_url": bson.M{"$first": "$spotify.cover_url"}, + }}}, + bson.D{{Key: "$sort", Value: bson.M{"tracks_count": -1}}}, + bson.D{{Key: "$limit", Value: limit}}, + } + + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("topics aggregation failed: %w", err) + } + defer cursor.Close(ctx) + + var results []struct { + ID string `bson:"_id"` + TopicID int64 `bson:"topic_id"` + TracksCount int64 `bson:"tracks_count"` + CoverURL string `bson:"cover_url"` + } + + if err := cursor.All(ctx, &results); err != nil { + return nil, fmt.Errorf("failed to decode topics: %w", err) + } + + topics := make([]*models.TopicItem, 0, len(results)) + for _, res := range results { + topics = append(topics, &models.TopicItem{ + TopicID: res.TopicID, + TopicName: res.ID, + TracksCount: res.TracksCount, + CoverURL: res.CoverURL, + }) + } + + return topics, nil +} + +func (r *mongoTrackRepository) GetChannelIDs(ctx context.Context) ([]int64, error) { + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "source_chat_id": bson.M{"$exists": true, "$ne": 0}, + } + + distinctRes := r.col.Distinct(ctx, "source_chat_id", filter) + if err := distinctRes.Err(); err != nil { + return nil, fmt.Errorf("failed to get channel ids: %w", err) + } + + var rawValues []interface{} + if err := distinctRes.Decode(&rawValues); err != nil { + return nil, fmt.Errorf("failed to decode channel ids: %w", err) + } + + var channelIDs []int64 + for _, v := range rawValues { + switch n := v.(type) { + case int64: + channelIDs = append(channelIDs, n) + case int32: + channelIDs = append(channelIDs, int64(n)) + case int: + channelIDs = append(channelIDs, int64(n)) + case float64: + channelIDs = append(channelIDs, int64(n)) + } + } + + return channelIDs, nil +} + +func (r *mongoTrackRepository) IncrementPlayCount(ctx context.Context, id string) error { + filter := bson.M{"_id": id} + update := bson.M{ + "$inc": bson.M{"play_count": 1}, + } + _, err := r.col.UpdateOne(ctx, filter, update) + return err +} diff --git a/internal/server/docs.go b/internal/server/docs.go index 053fc96..ba6af48 100644 --- a/internal/server/docs.go +++ b/internal/server/docs.go @@ -24,21 +24,7 @@ const openAPISpecJSON = `{ "summary": "Root Service Info", "description": "Returns basic service metadata, status, and running version.", "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "service": { "type": "string", "example": "StreamGO" }, - "status": { "type": "string", "example": "running" }, - "version": { "type": "string", "example": "1.0.0" } - } - } - } - } - } + "200": { "description": "Successful Response" } } } }, @@ -46,24 +32,129 @@ const openAPISpecJSON = `{ "get": { "tags": ["System"], "summary": "Health Check", - "description": "Returns server uptime, current time, and real-time MongoDB connectivity status.", + "description": "Returns server uptime, current time, Telegram client state, and real-time MongoDB connectivity status.", "responses": { - "200": { - "description": "Health status response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { "type": "string", "example": "ok" }, - "database": { "type": "string", "example": "healthy" }, - "uptime": { "type": "string", "example": "1h23m45s" }, - "time": { "type": "string", "example": "2026-09-18T11:22:33Z" } - } - } - } - } - } + "200": { "description": "Health status response" } + } + } + }, + "/tracks": { + "get": { + "tags": ["Tracks"], + "summary": "Browse / List Tracks", + "description": "Paginated track list with sorting, topic filtering, and channel filtering.", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["updated_at", "play_count", "created_at"] } }, + { "name": "topic", "in": "query", "schema": { "type": "string" } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { + "200": { "description": "Paginated list of browse items" } + } + } + }, + "/tracks/{id}": { + "get": { + "tags": ["Tracks"], + "summary": "Get Track by ID", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Full track entity" }, + "404": { "description": "Track not found" } + } + } + }, + "/tracks/random": { + "get": { + "tags": ["Tracks"], + "summary": "Get Random Tracks", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { + "200": { "description": "Randomized selection of tracks" } + } + } + }, + "/search": { + "get": { + "tags": ["Tracks"], + "summary": "Search Tracks", + "description": "Search tracks by title, artist, performer, or album.", + "parameters": [ + { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { + "200": { "description": "Search results matching query" } + } + } + }, + "/topics": { + "get": { + "tags": ["Topics"], + "summary": "List Unique Topics", + "description": "Aggregates unique topic names with total track counts and artwork.", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100 } } + ], + "responses": { + "200": { "description": "Aggregated topic list" } + } + } + }, + "/topics/{name}/tracks": { + "get": { + "tags": ["Topics"], + "summary": "Browse Topic Tracks", + "parameters": [ + { "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } } + ], + "responses": { + "200": { "description": "Paginated tracks belonging to topic" } + } + } + }, + "/channelids": { + "get": { + "tags": ["Topics"], + "summary": "Get Indexed Channel IDs", + "responses": { + "200": { "description": "List of indexed Telegram source channels" } + } + } + }, + "/tracks/{id}/stream": { + "get": { + "tags": ["Streaming"], + "summary": "Stream Track Audio", + "description": "Streams raw audio byte stream supporting HTTP 206 Range headers.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "Range", "in": "header", "schema": { "type": "string", "example": "bytes=0-1048575" } } + ], + "responses": { + "200": { "description": "Complete audio file" }, + "206": { "description": "Partial byte range of audio" }, + "416": { "description": "Range Not Satisfiable" } + } + }, + "head": { + "tags": ["Streaming"], + "summary": "Inspect Audio Headers", + "description": "Returns audio headers (Content-Length, Accept-Ranges, Content-Type) without body.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Audio headers available" } } } } diff --git a/internal/server/router.go b/internal/server/router.go index fd5a7fa..d30d6fc 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -10,9 +10,13 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "streamgo/internal/api/handlers" "streamgo/internal/config" "streamgo/internal/database" "streamgo/internal/logger" + "streamgo/internal/repository" + "streamgo/internal/services" + "streamgo/internal/telegram" ) // Server encapsulates the Chi router, config, and database handle. @@ -20,17 +24,19 @@ type Server struct { Router *chi.Mux Config *config.Config DB *database.Client + TG *telegram.Service start time.Time } -// New creates and configures a new HTTP router with middlewares and core routes. -func New(cfg *config.Config, db *database.Client) *Server { +// New creates and configures a new HTTP router with middlewares and modular routes. +func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server { r := chi.NewRouter() s := &Server{ Router: r, Config: cfg, DB: db, + TG: tg, start: time.Now(), } @@ -57,7 +63,7 @@ func New(cfg *config.Config, db *database.Client) *Server { MaxAge: 300, })) - // Core Routes + // Core System Routes r.Get("/", s.handleIndex) r.Get("/health", s.handleHealth) @@ -70,6 +76,21 @@ func New(cfg *config.Config, db *database.Client) *Server { r.Get("/openapi.json", s.handleOpenAPISpec) r.Head("/openapi.json", s.handleOpenAPISpec) + // Mount Domain Routes when MongoDB is available + if db != nil { + trackRepo := repository.NewTrackRepository(db) + trackSvc := services.NewTrackService(trackRepo) + streamSvc := services.NewStreamService(trackRepo, tg) + + trackHandler := handlers.NewTrackHandler(trackSvc) + topicHandler := handlers.NewTopicHandler(trackSvc) + streamHandler := handlers.NewStreamHandler(streamSvc) + + trackHandler.Routes(r) + topicHandler.Routes(r) + streamHandler.Routes(r) + } + return s } @@ -93,9 +114,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { } } + tgStatus := "disabled" + if s.TG != nil { + if s.TG.IsReady() { + self := s.TG.Self() + if self != nil { + tgStatus = "connected (" + self.FirstName + ")" + } else { + tgStatus = "connected" + } + } else { + tgStatus = "connecting" + } + } + respondJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "database": dbStatus, + "telegram": tgStatus, "uptime": time.Since(s.start).String(), "time": time.Now().UTC().Format(time.RFC3339), }) diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go new file mode 100644 index 0000000..f8832d8 --- /dev/null +++ b/internal/services/stream_service.go @@ -0,0 +1,246 @@ +package services + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "streamgo/internal/logger" + "streamgo/internal/models" + "streamgo/internal/repository" + "streamgo/internal/telegram" +) + +var logStream = logger.New("stream") + +var rangeRegex = regexp.MustCompile(`^bytes=(\d*)-(\d*)$`) + +// StreamService coordinates audio range parsing and streaming. +type StreamService struct { + repo repository.TrackRepository + tgService *telegram.Service + mediaDir string +} + +// NewStreamService creates a new StreamService. +func NewStreamService(repo repository.TrackRepository, tg *telegram.Service) *StreamService { + mediaDir := "stream_media" + _ = os.MkdirAll(mediaDir, 0755) + _ = os.MkdirAll(filepath.Join(mediaDir, "alac_cache"), 0755) + + return &StreamService{ + repo: repo, + tgService: tg, + mediaDir: mediaDir, + } +} + +// ByteRange defines start and end byte offsets. +type ByteRange struct { + Start int64 + End int64 + Length int64 +} + +// ParseRange parses an HTTP Range header string into byte boundaries. +func ParseRange(rangeHeader string, totalSize int64) (*ByteRange, error) { + rangeHeader = strings.TrimSpace(rangeHeader) + if rangeHeader == "" { + return nil, nil + } + + matches := rangeRegex.FindStringSubmatch(rangeHeader) + if len(matches) != 3 { + return nil, fmt.Errorf("invalid range format: %s", rangeHeader) + } + + startStr := matches[1] + endStr := matches[2] + + var start, end int64 + + if startStr == "" && endStr == "" { + return nil, fmt.Errorf("empty range boundaries") + } + + if startStr == "" { + // e.g. bytes=-500 (last 500 bytes) + n, err := strconv.ParseInt(endStr, 10, 64) + if err != nil || n <= 0 { + return nil, fmt.Errorf("invalid range offset: %s", endStr) + } + start = totalSize - n + if start < 0 { + start = 0 + } + end = totalSize - 1 + } else { + var err error + start, err = strconv.ParseInt(startStr, 10, 64) + if err != nil || start < 0 { + return nil, fmt.Errorf("invalid range start: %s", startStr) + } + + if endStr == "" { + end = totalSize - 1 + } else { + end, err = strconv.ParseInt(endStr, 10, 64) + if err != nil || end < start { + return nil, fmt.Errorf("invalid range end: %s", endStr) + } + if end >= totalSize { + end = totalSize - 1 + } + } + } + + if start >= totalSize { + return nil, fmt.Errorf("range start %d out of bounds (total: %d)", start, totalSize) + } + + return &ByteRange{ + Start: start, + End: end, + Length: end - start + 1, + }, nil +} + +// StreamTrack handles streaming audio for a track ID with HTTP Range support. +func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trackID string) { + ctx := r.Context() + + track, err := s.repo.GetByID(ctx, trackID) + if err != nil { + http.Error(w, "Failed to retrieve track", http.StatusInternalServerError) + return + } + if track == nil { + http.Error(w, "Track not found", http.StatusNotFound) + return + } + + // 1. Determine total size + totalSize := track.Audio.FileSize + if totalSize <= 0 { + totalSize = track.Telegram.FileSize + } + if totalSize <= 0 { + // Fallback default for unknown sizes (approx 10MB) + totalSize = 10 * 1024 * 1024 + } + + // 2. Determine mime type + mimeType := track.Audio.MimeType + if mimeType == "" { + mimeType = track.Telegram.MimeType + } + if mimeType == "" { + mimeType = "audio/mpeg" + } + + // 3. Clean filename for Content-Disposition + filename := track.Telegram.FileName + if filename == "" { + filename = fmt.Sprintf("%s.mp3", track.ID) + } + + // Check if local cache file exists (e.g. decoded ALAC/FLAC) + cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) + if info, err := os.Stat(cacheFile); err == nil && info.Size() > 10240 { + f, err := os.Open(cacheFile) + if err == nil { + defer f.Close() + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) + http.ServeContent(w, r, filename, info.ModTime(), f) + return + } + } + + // 4. Handle Range Header + rangeHeader := r.Header.Get("Range") + var byteRange *ByteRange + if rangeHeader != "" { + var err error + byteRange, err = ParseRange(rangeHeader, totalSize) + if err != nil { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + http.Error(w, "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable) + return + } + } + + // 5. Setup Response Headers + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) + + status := http.StatusOK + var contentLength int64 = totalSize + + if byteRange != nil { + status = http.StatusPartialContent + contentLength = byteRange.Length + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", byteRange.Start, byteRange.End, totalSize)) + } + w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10)) + + // If HEAD request, emit headers and finish immediately + if r.Method == http.MethodHead { + w.WriteHeader(status) + return + } + + // 6. Asynchronously increment play count + go func(tid string) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.repo.IncrementPlayCount(timeoutCtx, tid) + }(track.ID) + + w.WriteHeader(status) + + // Check Telegram client connectivity + if s.tgService == nil || !s.tgService.IsReady() { + logStream.Warnf("Telegram client not ready for track %s", trackID) + // If Telegram client isn't ready yet, write an empty or silence frame to avoid hanging browser + return + } + + // Stream chunks directly from Telegram to HTTP Response + s.streamFromTelegram(ctx, w, track, byteRange, totalSize) +} + +func (s *StreamService) streamFromTelegram( + ctx context.Context, + w io.Writer, + track *models.Track, + byteRange *ByteRange, + totalSize int64, +) { + // For gotd streaming, calculate start offset and limit + var startOffset int64 = 0 + var bytesRemaining int64 = totalSize + + if byteRange != nil { + startOffset = byteRange.Start + bytesRemaining = byteRange.Length + } + + logStream.Infof("streaming track %s: offset=%d, length=%d", track.ID, startOffset, bytesRemaining) + + // Note: Chunk streaming integration with Telegram MTProto uses gotd Downloader + // We handle client disconnections cleanly via ctx.Done() + select { + case <-ctx.Done(): + logStream.Debugf("client disconnected while streaming track %s", track.ID) + return + default: + } +} diff --git a/internal/services/stream_service_test.go b/internal/services/stream_service_test.go new file mode 100644 index 0000000..1f7ab57 --- /dev/null +++ b/internal/services/stream_service_test.go @@ -0,0 +1,91 @@ +package services + +import ( + "testing" +) + +func TestParseRange(t *testing.T) { + totalSize := int64(1000) + + tests := []struct { + name string + rangeHeader string + wantStart int64 + wantEnd int64 + wantLen int64 + wantErr bool + }{ + { + name: "Empty header", + rangeHeader: "", + wantStart: 0, + wantEnd: 0, + wantLen: 0, + wantErr: false, + }, + { + name: "Valid range 0-499", + rangeHeader: "bytes=0-499", + wantStart: 0, + wantEnd: 499, + wantLen: 500, + wantErr: false, + }, + { + name: "Valid range 500-", + rangeHeader: "bytes=500-", + wantStart: 500, + wantEnd: 999, + wantLen: 500, + wantErr: false, + }, + { + name: "Suffix range -200", + rangeHeader: "bytes=-200", + wantStart: 800, + wantEnd: 999, + wantLen: 200, + wantErr: false, + }, + { + name: "Exceeds total size clamped", + rangeHeader: "bytes=900-2000", + wantStart: 900, + wantEnd: 999, + wantLen: 100, + wantErr: false, + }, + { + name: "Invalid start > total", + rangeHeader: "bytes=1500-", + wantErr: true, + }, + { + name: "Invalid start > end", + rangeHeader: "bytes=500-400", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRange(tt.rangeHeader, totalSize) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseRange(%q) error = %v, wantErr %v", tt.rangeHeader, err, tt.wantErr) + } + if tt.wantErr { + return + } + if tt.rangeHeader == "" { + if got != nil { + t.Fatalf("ParseRange(%q) expected nil, got %v", tt.rangeHeader, got) + } + return + } + if got.Start != tt.wantStart || got.End != tt.wantEnd || got.Length != tt.wantLen { + t.Fatalf("ParseRange(%q) = {%d, %d, %d}, want {%d, %d, %d}", + tt.rangeHeader, got.Start, got.End, got.Length, tt.wantStart, tt.wantEnd, tt.wantLen) + } + }) + } +} diff --git a/internal/services/track_service.go b/internal/services/track_service.go new file mode 100644 index 0000000..a5f4f6e --- /dev/null +++ b/internal/services/track_service.go @@ -0,0 +1,129 @@ +package services + +import ( + "context" + "math" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// TrackService coordinates track-related business operations. +type TrackService struct { + repo repository.TrackRepository +} + +// NewTrackService creates a new TrackService with the given repository. +func NewTrackService(repo repository.TrackRepository) *TrackService { + return &TrackService{repo: repo} +} + +// GetTrack retrieves a single track by its ID. +func (s *TrackService) GetTrack(ctx context.Context, id string) (*models.Track, error) { + return s.repo.GetByID(ctx, id) +} + +// Browse retrieves a paginated feed of tracks converted to lightweight BrowseItems. +func (s *TrackService) Browse( + ctx context.Context, + page, perPage int, + sortField, topicName string, + channelID int64, +) (*models.BrowseResponse, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 100 { + perPage = 100 + } + + tracks, total, err := s.repo.List(ctx, page, perPage, sortField, topicName, channelID) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + totalPages := 0 + if total > 0 { + totalPages = int(math.Ceil(float64(total) / float64(perPage))) + } + + return &models.BrowseResponse{ + Items: items, + Total: total, + Page: page, + PerPage: perPage, + TotalPages: totalPages, + }, nil +} + +// Search queries tracks by artist, title, or album matching. +func (s *TrackService) Search(ctx context.Context, query string, limit int) ([]*models.BrowseItem, error) { + tracks, err := s.repo.Search(ctx, query, limit) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + return items, nil +} + +// GetRandom retrieves a randomized selection of tracks. +func (s *TrackService) GetRandom(ctx context.Context, limit int, channelID int64) ([]*models.BrowseItem, error) { + tracks, err := s.repo.Random(ctx, limit, channelID) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + return items, nil +} + +// GetTopics returns aggregated topic lists with counts. +func (s *TrackService) GetTopics(ctx context.Context, limit int) (*models.TopicsResponse, error) { + topics, err := s.repo.GetTopics(ctx, limit) + if err != nil { + return nil, err + } + + return &models.TopicsResponse{ + OK: true, + Total: len(topics), + Items: topics, + }, nil +} + +// GetChannelIDs returns all unique indexed Telegram source channel IDs. +func (s *TrackService) GetChannelIDs(ctx context.Context) (*models.ChannelIDsResponse, error) { + cids, err := s.repo.GetChannelIDs(ctx) + if err != nil { + return nil, err + } + + items := make([]models.ChannelItem, 0, len(cids)) + for _, id := range cids { + items = append(items, models.ChannelItem{ + ID: id, + }) + } + + return &models.ChannelIDsResponse{ + OK: true, + Items: items, + }, nil +} From 29b3e5b12a2495baad89928809d19e011c92bd96 Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 18:13:10 +0530 Subject: [PATCH 05/39] feat: implement bit-perfect Telegram MTProto live audio streaming with HTTP Range and seeking --- internal/services/stream_service.go | 88 ++++++++++++++-- internal/telegram/fileid.go | 155 ++++++++++++++++++++++++++++ internal/telegram/fileid_test.go | 27 +++++ 3 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 internal/telegram/fileid.go create mode 100644 internal/telegram/fileid_test.go diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index f8832d8..439fba9 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "io" "net/http" @@ -12,6 +13,8 @@ import ( "strings" "time" + "github.com/gotd/td/tg" + "streamgo/internal/logger" "streamgo/internal/models" "streamgo/internal/repository" @@ -71,7 +74,6 @@ func ParseRange(rangeHeader string, totalSize int64) (*ByteRange, error) { } if startStr == "" { - // e.g. bytes=-500 (last 500 bytes) n, err := strconv.ParseInt(endStr, 10, 64) if err != nil || n <= 0 { return nil, fmt.Errorf("invalid range offset: %s", endStr) @@ -132,7 +134,6 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac totalSize = track.Telegram.FileSize } if totalSize <= 0 { - // Fallback default for unknown sizes (approx 10MB) totalSize = 10 * 1024 * 1024 } @@ -209,7 +210,6 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac // Check Telegram client connectivity if s.tgService == nil || !s.tgService.IsReady() { logStream.Warnf("Telegram client not ready for track %s", trackID) - // If Telegram client isn't ready yet, write an empty or silence frame to avoid hanging browser return } @@ -217,14 +217,57 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac s.streamFromTelegram(ctx, w, track, byteRange, totalSize) } +type rangeWriter struct { + w io.Writer + skip int64 + remain int64 + flusher http.Flusher +} + +func (rw *rangeWriter) Write(p []byte) (int, error) { + if rw.remain <= 0 { + return 0, io.EOF + } + + n := len(p) + if rw.skip > 0 { + if int64(n) <= rw.skip { + rw.skip -= int64(n) + return n, nil + } + p = p[rw.skip:] + rw.skip = 0 + } + + toWrite := p + if int64(len(toWrite)) > rw.remain { + toWrite = toWrite[:rw.remain] + } + + written, err := rw.w.Write(toWrite) + rw.remain -= int64(written) + + if rw.flusher != nil { + rw.flusher.Flush() + } + + if err != nil { + return written, err + } + if rw.remain <= 0 { + return n, io.EOF + } + + return n, nil +} + func (s *StreamService) streamFromTelegram( ctx context.Context, - w io.Writer, + w http.ResponseWriter, track *models.Track, byteRange *ByteRange, totalSize int64, ) { - // For gotd streaming, calculate start offset and limit var startOffset int64 = 0 var bytesRemaining int64 = totalSize @@ -235,12 +278,35 @@ func (s *StreamService) streamFromTelegram( logStream.Infof("streaming track %s: offset=%d, length=%d", track.ID, startOffset, bytesRemaining) - // Note: Chunk streaming integration with Telegram MTProto uses gotd Downloader - // We handle client disconnections cleanly via ctx.Done() - select { - case <-ctx.Done(): - logStream.Debugf("client disconnected while streaming track %s", track.ID) + fileID := track.Telegram.FileID + if fileID == "" { + logStream.Errorf("track %s has no telegram file_id", track.ID) + return + } + + decoded, err := telegram.DecodeFileID(fileID) + if err != nil { + logStream.Errorf("failed to decode file_id for track %s: %v", track.ID, err) return - default: + } + + location := &tg.InputDocumentFileLocation{ + ID: decoded.MediaID, + AccessHash: decoded.AccessHash, + FileReference: decoded.FileReference, + } + + flusher, _ := w.(http.Flusher) + rw := &rangeWriter{ + w: w, + skip: startOffset, + remain: bytesRemaining, + flusher: flusher, + } + + downloader := s.tgService.Client.Downloader() + _, err = downloader.Download(s.tgService.API, location).Stream(ctx, rw) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) { + logStream.Warnf("streaming ended with error for track %s: %v", track.ID, err) } } diff --git a/internal/telegram/fileid.go b/internal/telegram/fileid.go new file mode 100644 index 0000000..9e7ba9e --- /dev/null +++ b/internal/telegram/fileid.go @@ -0,0 +1,155 @@ +package telegram + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "io" + "strings" +) + +const ( + webLocationFlag = 1 << 24 + fileReferenceFlag = 1 << 25 +) + +// DecodedFileID contains unpacked MTProto parameters from a Telegram file_id. +type DecodedFileID struct { + Major byte + Minor byte + FileType int32 + DCID int32 + MediaID int64 + AccessHash int64 + FileReference []byte +} + +// DecodeFileID parses a Pyrogram/TDLib encoded file_id string. +func DecodeFileID(fileID string) (*DecodedFileID, error) { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return nil, errors.New("empty file_id") + } + + // 1. Base64 URL decode (handle unpadded base64) + raw, err := base64.RawURLEncoding.DecodeString(fileID) + if err != nil { + // Fallback to standard URL encoding + raw, err = base64.URLEncoding.DecodeString(fileID) + if err != nil { + return nil, fmt.Errorf("base64 decode failed: %w", err) + } + } + + // 2. Zero-value RLE decode + decoded := rleDecode(raw) + if len(decoded) < 10 { + return nil, errors.New("decoded file_id is too short") + } + + // 3. Read version + major := decoded[len(decoded)-1] + minor := byte(0) + payload := decoded[:len(decoded)-1] + if major >= 4 { + minor = decoded[len(decoded)-2] + payload = decoded[:len(decoded)-2] + } + + buf := bytes.NewReader(payload) + + var fileType, dcID int32 + if err := binary.Read(buf, binary.LittleEndian, &fileType); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.LittleEndian, &dcID); err != nil { + return nil, err + } + + hasFileReference := (fileType & fileReferenceFlag) != 0 + fileType = fileType &^ webLocationFlag &^ fileReferenceFlag + + var fileReference []byte + if hasFileReference { + ref, err := readTLBytes(buf) + if err != nil { + return nil, fmt.Errorf("failed to read file_reference: %w", err) + } + fileReference = ref + } + + var mediaID, accessHash int64 + if err := binary.Read(buf, binary.LittleEndian, &mediaID); err != nil { + return nil, fmt.Errorf("failed to read mediaID: %w", err) + } + if err := binary.Read(buf, binary.LittleEndian, &accessHash); err != nil { + return nil, fmt.Errorf("failed to read accessHash: %w", err) + } + + return &DecodedFileID{ + Major: major, + Minor: minor, + FileType: fileType, + DCID: dcID, + MediaID: mediaID, + AccessHash: accessHash, + FileReference: fileReference, + }, nil +} + +func rleDecode(src []byte) []byte { + var dst []byte + zero := false + + for _, b := range src { + if b == 0 { + zero = true + continue + } + if zero { + dst = append(dst, bytes.Repeat([]byte{0}, int(b))...) + zero = false + } else { + dst = append(dst, b) + } + } + return dst +} + +func readTLBytes(r io.Reader) ([]byte, error) { + var firstByte [1]byte + if _, err := io.ReadFull(r, firstByte[:]); err != nil { + return nil, err + } + + var length int + var padding int + + if firstByte[0] < 254 { + length = int(firstByte[0]) + padding = (4 - ((length + 1) % 4)) % 4 + } else { + var lenBytes [3]byte + if _, err := io.ReadFull(r, lenBytes[:]); err != nil { + return nil, err + } + length = int(lenBytes[0]) | int(lenBytes[1])<<8 | int(lenBytes[2])<<16 + padding = (4 - (length % 4)) % 4 + } + + data := make([]byte, length) + if _, err := io.ReadFull(r, data); err != nil { + return nil, err + } + + if padding > 0 { + pad := make([]byte, padding) + if _, err := io.ReadFull(r, pad); err != nil { + return nil, err + } + } + + return data, nil +} diff --git a/internal/telegram/fileid_test.go b/internal/telegram/fileid_test.go new file mode 100644 index 0000000..b9b083b --- /dev/null +++ b/internal/telegram/fileid_test.go @@ -0,0 +1,27 @@ +package telegram + +import ( + "testing" +) + +func TestDecodeFileID(t *testing.T) { + sampleFileID := "CQACAgEAAyEFAAMBAAFQpOAAA1JqrOHy9AzfcqpOSsZ1W9OvOkJZ4AACwQgAAu-KYEWQ5P1U3bsqzB4E" + + decoded, err := DecodeFileID(sampleFileID) + if err != nil { + t.Fatalf("DecodeFileID failed: %v", err) + } + + if decoded.MediaID != 4999148345483069633 { + t.Errorf("expected MediaID 4999148345483069633, got %d", decoded.MediaID) + } + if decoded.AccessHash != -3734966381662313328 { + t.Errorf("expected AccessHash -3734966381662313328, got %d", decoded.AccessHash) + } + if len(decoded.FileReference) != 33 { + t.Errorf("expected FileReference length 33, got %d", len(decoded.FileReference)) + } + if decoded.DCID != 1 { + t.Errorf("expected DCID 1, got %d", decoded.DCID) + } +} From 883450922e6357f3db534718e9912c9915ae41c8 Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 18:40:15 +0530 Subject: [PATCH 06/39] feat: add alternative titles support, artists, albums, playlists, favourites, and auth endpoints --- internal/api/handlers/albums.go | 88 +++ internal/api/handlers/artists.go | 88 +++ internal/api/handlers/auth.go | 136 +++++ internal/api/handlers/favourites.go | 303 +++++++++++ internal/api/handlers/media_extra.go | 139 +++++ internal/api/handlers/playlists.go | 303 +++++++++++ internal/api/middleware/auth.go | 87 +++ internal/config/config.go | 3 + internal/models/artist_album.go | 59 ++ internal/models/auth_user.go | 45 ++ internal/models/favourite_playlist.go | 83 +++ internal/models/track.go | 86 +-- internal/repository/artist_album_repo.go | 231 ++++++++ .../repository/favourite_playlist_repo.go | 506 ++++++++++++++++++ internal/repository/track_repo.go | 46 ++ internal/repository/user_repo.go | 84 +++ internal/server/docs.go | 384 ++++++++++++- internal/server/router.go | 28 +- internal/services/artist_album_service.go | 173 ++++++ internal/services/auth_service.go | 398 ++++++++++++++ internal/services/auth_service_test.go | 112 ++++ .../services/favourite_playlist_service.go | 294 ++++++++++ scripts/verify_endpoints.go | 165 ++++++ 23 files changed, 3778 insertions(+), 63 deletions(-) create mode 100644 internal/api/handlers/albums.go create mode 100644 internal/api/handlers/artists.go create mode 100644 internal/api/handlers/auth.go create mode 100644 internal/api/handlers/favourites.go create mode 100644 internal/api/handlers/media_extra.go create mode 100644 internal/api/handlers/playlists.go create mode 100644 internal/api/middleware/auth.go create mode 100644 internal/models/artist_album.go create mode 100644 internal/models/auth_user.go create mode 100644 internal/models/favourite_playlist.go create mode 100644 internal/repository/artist_album_repo.go create mode 100644 internal/repository/favourite_playlist_repo.go create mode 100644 internal/repository/user_repo.go create mode 100644 internal/services/artist_album_service.go create mode 100644 internal/services/auth_service.go create mode 100644 internal/services/auth_service_test.go create mode 100644 internal/services/favourite_playlist_service.go create mode 100644 scripts/verify_endpoints.go diff --git a/internal/api/handlers/albums.go b/internal/api/handlers/albums.go new file mode 100644 index 0000000..215f487 --- /dev/null +++ b/internal/api/handlers/albums.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// AlbumHandler handles HTTP endpoints for albums. +type AlbumHandler struct { + svc *services.ArtistAlbumService +} + +// NewAlbumHandler creates an AlbumHandler instance. +func NewAlbumHandler(svc *services.ArtistAlbumService) *AlbumHandler { + return &AlbumHandler{svc: svc} +} + +// Routes mounts album endpoints on Chi router. +func (h *AlbumHandler) Routes(r chi.Router) { + r.Get("/albums", h.List) + r.Get("/albums/{id}", h.GetByID) + r.Get("/albums/{id}/tracks", h.GetTracks) +} + +// List handles GET /albums with pagination and optional artist filter. +func (h *AlbumHandler) List(w http.ResponseWriter, r *http.Request) { + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 20) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + artistFilter := strings.TrimSpace(r.URL.Query().Get("artist")) + + resp, err := h.svc.ListAlbums(r.Context(), page, perPage, artistFilter) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /albums/{id}. +func (h *AlbumHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "album id is required") + return + } + + detail, err := h.svc.GetAlbumDetail(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if detail == nil { + api.RespondError(w, http.StatusNotFound, "album not found") + return + } + + api.RespondJSON(w, http.StatusOK, detail) +} + +// GetTracks handles GET /albums/{id}/tracks. +func (h *AlbumHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "album id is required") + return + } + + tracks, err := h.svc.GetAlbumTracks(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "total": len(tracks), + "items": tracks, + }) +} diff --git a/internal/api/handlers/artists.go b/internal/api/handlers/artists.go new file mode 100644 index 0000000..45fe5fc --- /dev/null +++ b/internal/api/handlers/artists.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// ArtistHandler handles endpoints for artist discovery and profiles. +type ArtistHandler struct { + svc *services.ArtistAlbumService +} + +// NewArtistHandler creates an ArtistHandler instance. +func NewArtistHandler(svc *services.ArtistAlbumService) *ArtistHandler { + return &ArtistHandler{svc: svc} +} + +// Routes mounts artist endpoints on the Chi router. +func (h *ArtistHandler) Routes(r chi.Router) { + r.Get("/artists", h.List) + r.Get("/artists/{id}", h.GetByID) + r.Get("/artists/{id}/tracks", h.GetTracks) +} + +// List handles GET /artists with pagination. +func (h *ArtistHandler) List(w http.ResponseWriter, r *http.Request) { + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 20) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + resp, err := h.svc.ListArtists(r.Context(), page, perPage) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /artists/{id}. +func (h *ArtistHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "artist id is required") + return + } + + detail, err := h.svc.GetArtistDetail(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if detail == nil { + api.RespondError(w, http.StatusNotFound, "artist not found") + return + } + + api.RespondJSON(w, http.StatusOK, detail) +} + +// GetTracks handles GET /artists/{id}/tracks. +func (h *ArtistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "artist id is required") + return + } + + limit := api.ParseQueryInt(r, "limit", 50) + tracks, err := h.svc.GetArtistTracks(r.Context(), id, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "total": len(tracks), + "items": tracks, + }) +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..46a33bc --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// AuthHandler handles authentication routes for Telegram WebApp and widgets. +type AuthHandler struct { + cfg *config.Config + authSvc *services.AuthService +} + +// NewAuthHandler creates an AuthHandler instance. +func NewAuthHandler(cfg *config.Config, authSvc *services.AuthService) *AuthHandler { + return &AuthHandler{ + cfg: cfg, + authSvc: authSvc, + } +} + +// Routes registers authentication endpoints on Chi router. +func (h *AuthHandler) Routes(r chi.Router) { + r.Post("/auth/telegram", h.TelegramLogin) + r.Post("/auth/tg/login", h.TelegramLogin) + r.Post("/auth/login", h.TelegramLogin) + r.Post("/webapp/verify", h.TelegramLogin) + + r.Post("/auth/telegram/widget", h.WidgetLogin) + + r.Get("/auth/telegram/config", h.Config) + r.Get("/webapp/config", h.Config) + + // Authenticated profile route + r.Group(func(sub chi.Router) { + sub.Use(middleware.RequireAuth(h.authSvc)) + sub.Get("/auth/me", h.Me) + }) +} + +// TelegramLogin handles POST /auth/telegram and /webapp/verify. +func (h *AuthHandler) TelegramLogin(w http.ResponseWriter, r *http.Request) { + var initData string + + contentType := r.Header.Get("Content-Type") + if strings.Contains(contentType, "application/x-www-form-urlencoded") || strings.Contains(contentType, "multipart/form-data") { + _ = r.ParseForm() + initData = r.FormValue("init_data") + if initData == "" { + initData = r.FormValue("initData") + } + } else { + var payload models.TelegramWebAppRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { + initData = payload.InitData + } + } + + initData = strings.TrimSpace(initData) + if initData == "" { + api.RespondError(w, http.StatusBadRequest, "init_data is required") + return + } + + resp, err := h.authSvc.AuthenticateTelegram(r.Context(), initData) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// WidgetLogin handles POST /auth/telegram/widget. +func (h *AuthHandler) WidgetLogin(w http.ResponseWriter, r *http.Request) { + var payload models.TelegramWidgetRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid widget payload") + return + } + + resp, err := h.authSvc.AuthenticateWidget(r.Context(), payload) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// Me handles GET /auth/me. +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + user, err := h.authSvc.GetUserByID(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if user == nil { + api.RespondError(w, http.StatusNotFound, "user not found") + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user": user, + }) +} + +// Config handles GET /auth/telegram/config and GET /webapp/config. +func (h *AuthHandler) Config(w http.ResponseWriter, r *http.Request) { + botID := "" + if parts := strings.Split(h.cfg.BotToken, ":"); len(parts) > 0 { + botID = parts[0] + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "bot_id": botID, + "has_auth": true, + }) +} diff --git a/internal/api/handlers/favourites.go b/internal/api/handlers/favourites.go new file mode 100644 index 0000000..ccf22cf --- /dev/null +++ b/internal/api/handlers/favourites.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// FavouriteHandler manages user favourites, history, and telemetry listening events. +type FavouriteHandler struct { + favSvc *services.FavouritePlaylistService + authSvc *services.AuthService +} + +// NewFavouriteHandler creates a new FavouriteHandler instance. +func NewFavouriteHandler(favSvc *services.FavouritePlaylistService, authSvc *services.AuthService) *FavouriteHandler { + return &FavouriteHandler{ + favSvc: favSvc, + authSvc: authSvc, + } +} + +// Routes mounts favourite endpoints under Chi router. +func (h *FavouriteHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + optAuth := middleware.OptionalAuth(h.authSvc) + + // Favourites (with and without /me prefix) + r.Group(func(sub chi.Router) { + sub.Use(requireAuth) + sub.Post("/favourites", h.AddFavourite) + sub.Post("/me/favourites", h.AddFavourite) + sub.Delete("/favourites/{id}", h.RemoveFavourite) + sub.Delete("/me/favourites/{id}", h.RemoveFavourite) + sub.Get("/favourites", h.ListFavourites) + sub.Get("/me/favourites", h.ListFavourites) + sub.Get("/history", h.GetHistory) + sub.Get("/me/history", h.GetHistory) + sub.Get("/top-played", h.GetHistory) + sub.Get("/me/top-played", h.GetHistory) + + // Artist follows + sub.Post("/artists/favourites", h.AddArtistFavourite) + sub.Post("/me/artists/favourites", h.AddArtistFavourite) + sub.Post("/me/artists/favorite", h.AddArtistFavourite) + sub.Delete("/artists/favourites/{id}", h.RemoveArtistFavourite) + sub.Delete("/me/artists/favourites/{id}", h.RemoveArtistFavourite) + sub.Delete("/me/artists/favorite/{id}", h.RemoveArtistFavourite) + }) + + r.Group(func(sub chi.Router) { + sub.Use(optAuth) + sub.Get("/favourites/ids", h.ListFavouriteIDs) + sub.Get("/me/favourites/ids", h.ListFavouriteIDs) + sub.Get("/artists/favourites/ids", h.ListFavouriteArtistIDs) + sub.Get("/me/artists/favourites/ids", h.ListFavouriteArtistIDs) + sub.Post("/listening-events", h.RecordListeningEvents) + sub.Post("/me/listening-events", h.RecordListeningEvents) + }) +} + +// AddFavourite handles POST /favourites. +func (h *FavouriteHandler) AddFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var payload struct { + TrackID string `json:"track_id"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + trackID := strings.TrimSpace(payload.TrackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track_id is required") + return + } + + alreadyExists, err := h.favSvc.AddFavourite(r.Context(), userID, trackID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": alreadyExists, + }) +} + +// RemoveFavourite handles DELETE /favourites/{id}. +func (h *FavouriteHandler) RemoveFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "track_id is required") + return + } + + deleted, err := h.favSvc.RemoveFavourite(r.Context(), userID, id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": deleted, + }) +} + +// ListFavouriteIDs handles GET /favourites/ids. +func (h *FavouriteHandler) ListFavouriteIDs(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondJSON(w, http.StatusOK, models.FavouriteIDsResponse{ + OK: true, + IDs: []string{}, + }) + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 200) + + resp, err := h.favSvc.GetFavouriteIDs(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// ListFavourites handles GET /favourites. +func (h *FavouriteHandler) ListFavourites(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 20) + + resp, err := h.favSvc.GetFavourites(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetHistory handles GET /history and GET /top-played. +func (h *FavouriteHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + limit := api.ParseQueryInt(r, "limit", 50) + resp, err := h.favSvc.GetUserHistory(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// AddArtistFavourite handles POST /artists/favourites. +func (h *FavouriteHandler) AddArtistFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var aid string + var payload struct { + ArtistID string `json:"artist_id"` + ID string `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { + aid = payload.ArtistID + if aid == "" { + aid = payload.ID + } + } + if aid == "" { + aid = r.URL.Query().Get("artist_id") + if aid == "" { + aid = r.URL.Query().Get("id") + } + } + aid = strings.TrimSpace(aid) + if aid == "" { + api.RespondError(w, http.StatusBadRequest, "artist_id is required") + return + } + + alreadyExists, err := h.favSvc.AddFavouriteArtist(r.Context(), userID, aid) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": alreadyExists, + }) +} + +// RemoveArtistFavourite handles DELETE /artists/favourites/{id}. +func (h *FavouriteHandler) RemoveArtistFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "artist_id is required") + return + } + + deleted, err := h.favSvc.RemoveFavouriteArtist(r.Context(), userID, id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": deleted, + }) +} + +// ListFavouriteArtistIDs handles GET /artists/favourites/ids. +func (h *FavouriteHandler) ListFavouriteArtistIDs(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondJSON(w, http.StatusOK, models.FavouriteArtistsResponse{ + OK: true, + IDs: []string{}, + }) + return + } + + resp, err := h.favSvc.GetFavouriteArtistIDs(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// RecordListeningEvents handles POST /listening-events. +func (h *FavouriteHandler) RecordListeningEvents(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid listening events payload") + return + } + + count := len(payload.Events) + if count > 0 { + _ = h.favSvc.RecordListeningEvents(r.Context(), userID, payload.Events) + for _, ev := range payload.Events { + if userID > 0 && ev.TrackID != "" { + _ = h.favSvc.RecordHistory(r.Context(), userID, ev.TrackID, ev.PlayedAt) + } + } + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": count, + }) +} diff --git a/internal/api/handlers/media_extra.go b/internal/api/handlers/media_extra.go new file mode 100644 index 0000000..6b69d5d --- /dev/null +++ b/internal/api/handlers/media_extra.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// MediaExtraHandler handles static cover images and track lyrics. +type MediaExtraHandler struct { + trackSvc *services.TrackService +} + +// NewMediaExtraHandler creates a new MediaExtraHandler. +func NewMediaExtraHandler(trackSvc *services.TrackService) *MediaExtraHandler { + return &MediaExtraHandler{trackSvc: trackSvc} +} + +// Routes mounts cover and lyrics endpoints on Chi router. +func (h *MediaExtraHandler) Routes(r chi.Router) { + r.Get("/covers/file/{file_key}.png", h.GetCoverFile) + r.Head("/covers/file/{file_key}.png", h.GetCoverFile) + r.Get("/cover/{id}", h.GetCoverRedirect) + r.Head("/cover/{id}", h.GetCoverRedirect) + r.Get("/lyrics/{id}", h.GetLyrics) + r.Get("/tracks/{id}/lyrics", h.GetLyrics) +} + + +// GetCoverFile serves cached generated cover PNG files. +func (h *MediaExtraHandler) GetCoverFile(w http.ResponseWriter, r *http.Request) { + fileKey := strings.TrimSpace(chi.URLParam(r, "file_key")) + if fileKey == "" { + api.RespondError(w, http.StatusBadRequest, "file_key is required") + return + } + + // Look in GenCovers in current or parent dirs + searchDirs := []string{ + "GenCovers", + "../GenCovers", + "../../GenCovers", + "/home/misfit/Work/StreamXBot/GenCovers", + } + + var foundPath string + for _, dir := range searchDirs { + candidate := filepath.Join(dir, fmt.Sprintf("%s.png", fileKey)) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + foundPath = candidate + break + } + } + + if foundPath == "" { + api.RespondError(w, http.StatusNotFound, "cover not found") + return + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeFile(w, r, foundPath) +} + +// GetCoverRedirect resolves cover URL for a track and redirects. +func (h *MediaExtraHandler) GetCoverRedirect(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "id is required") + return + } + + track, err := h.trackSvc.GetTrack(r.Context(), id) + if err != nil || track == nil { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + + item := track.ToBrowseItem() + coverURL := item.CoverURL + if coverURL == "" { + coverURL = track.EffectiveCoverURL() + } + + if coverURL == "" { + api.RespondError(w, http.StatusNotFound, "cover not available") + return + } + + http.Redirect(w, r, coverURL, http.StatusFound) +} + +// GetLyrics returns lyrics for a track either in plain text or JSON. +func (h *MediaExtraHandler) GetLyrics(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + track, err := h.trackSvc.GetTrack(r.Context(), id) + if err != nil || track == nil { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + + lyrics := track.Lyrics + if lyrics == "" { + lyrics = track.Audio.Lyrics + } + + if lyrics == "" { + api.RespondError(w, http.StatusNotFound, "lyrics not found") + return + } + + + format := r.URL.Query().Get("format") + accept := r.Header.Get("Accept") + if format == "plain" || strings.Contains(accept, "text/plain") { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(lyrics)) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "track_id": id, + "lyrics": lyrics, + }) +} diff --git a/internal/api/handlers/playlists.go b/internal/api/handlers/playlists.go new file mode 100644 index 0000000..d402176 --- /dev/null +++ b/internal/api/handlers/playlists.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/services" +) + +// PlaylistHandler handles custom user playlist endpoints. +type PlaylistHandler struct { + favSvc *services.FavouritePlaylistService + authSvc *services.AuthService +} + +// NewPlaylistHandler creates a PlaylistHandler instance. +func NewPlaylistHandler(favSvc *services.FavouritePlaylistService, authSvc *services.AuthService) *PlaylistHandler { + return &PlaylistHandler{ + favSvc: favSvc, + authSvc: authSvc, + } +} + +// Routes registers all playlist routes on the Chi router. +func (h *PlaylistHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + optAuth := middleware.OptionalAuth(h.authSvc) + + // Protected routes (creation, modification, deletion) + r.Group(func(sub chi.Router) { + sub.Use(requireAuth) + sub.Post("/playlists", h.Create) + sub.Post("/me/playlists", h.Create) + sub.Get("/playlists", h.ListMine) + sub.Get("/me/playlists", h.ListMine) + + sub.Patch("/playlists/{id}", h.Update) + sub.Patch("/me/playlists/{id}", h.Update) + sub.Put("/playlists/{id}", h.Update) + sub.Put("/me/playlists/{id}", h.Update) + + sub.Delete("/playlists/{id}", h.Delete) + sub.Delete("/me/playlists/{id}", h.Delete) + + sub.Post("/playlists/{id}/tracks", h.AddTracks) + sub.Post("/me/playlists/{id}/tracks", h.AddTracks) + + sub.Delete("/playlists/{id}/tracks/{track_id}", h.RemoveTrack) + sub.Delete("/me/playlists/{id}/tracks/{track_id}", h.RemoveTrack) + + sub.Post("/playlists/{id}/reorder", h.Reorder) + sub.Post("/me/playlists/{id}/reorder", h.Reorder) + }) + + // Public / Optional-auth routes (viewing playlist tracks) + r.Group(func(sub chi.Router) { + sub.Use(optAuth) + sub.Get("/playlists/{id}", h.GetByID) + sub.Get("/me/playlists/{id}", h.GetByID) + sub.Get("/playlists/{id}/tracks", h.GetTracks) + sub.Get("/me/playlists/{id}/tracks", h.GetTracks) + }) +} + +// Create handles POST /playlists. +func (h *PlaylistHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var payload struct { + Title string `json:"title"` + Description string `json:"description"` + CoverURL string `json:"cover_url"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + p, err := h.favSvc.CreatePlaylist(r.Context(), userID, payload.Title, payload.Description, payload.CoverURL) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, p) +} + +// ListMine handles GET /playlists and GET /me/playlists. +func (h *PlaylistHandler) ListMine(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + resp, err := h.favSvc.GetUserPlaylists(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /playlists/{id}. +func (h *PlaylistHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + detail, err := h.favSvc.GetPlaylist(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if detail == nil { + api.RespondError(w, http.StatusNotFound, "playlist not found") + return + } + + api.RespondJSON(w, http.StatusOK, detail) +} + +// Update handles PATCH /playlists/{id}. +func (h *PlaylistHandler) Update(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + var payload struct { + Title string `json:"title"` + Description string `json:"description"` + CoverURL string `json:"cover_url"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + updated, err := h.favSvc.UpdatePlaylist(r.Context(), id, userID, payload.Title, payload.Description, payload.CoverURL) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if updated == nil { + api.RespondError(w, http.StatusNotFound, "playlist not found or unauthorized") + return + } + + api.RespondJSON(w, http.StatusOK, updated) +} + +// Delete handles DELETE /playlists/{id}. +func (h *PlaylistHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + deleted, err := h.favSvc.DeletePlaylist(r.Context(), id, userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": deleted, + }) +} + +// GetTracks handles GET /playlists/{id}/tracks. +func (h *PlaylistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + tracks, err := h.favSvc.GetPlaylistTracks(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "total": len(tracks), + "items": tracks, + }) +} + +// AddTracks handles POST /playlists/{id}/tracks. +func (h *PlaylistHandler) AddTracks(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + var payload struct { + TrackID string `json:"track_id"` + TrackIDs []string `json:"track_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var trackIDs []string + if len(payload.TrackIDs) > 0 { + trackIDs = payload.TrackIDs + } else if payload.TrackID != "" { + trackIDs = []string{payload.TrackID} + } + + if len(trackIDs) == 0 { + api.RespondError(w, http.StatusBadRequest, "track_ids cannot be empty") + return + } + + if err := h.favSvc.AddTracksToPlaylist(r.Context(), id, trackIDs); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(trackIDs), + }) +} + +// RemoveTrack handles DELETE /playlists/{id}/tracks/{track_id}. +func (h *PlaylistHandler) RemoveTrack(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + trackID := strings.TrimSpace(chi.URLParam(r, "track_id")) + if id == "" || trackID == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id and track id are required") + return + } + + if err := h.favSvc.RemoveTrackFromPlaylist(r.Context(), id, trackID); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": true, + }) +} + +// Reorder handles POST /playlists/{id}/reorder. +func (h *PlaylistHandler) Reorder(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + var payload struct { + TrackIDs []string `json:"track_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.favSvc.ReorderPlaylistTracks(r.Context(), id, payload.TrackIDs); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..a183326 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +type contextKey string + +const ( + userIDKey contextKey = "userID" +) + +// WithUserID injects a numeric user ID into context. +func WithUserID(ctx context.Context, userID int64) context.Context { + return context.WithValue(ctx, userIDKey, userID) +} + +// GetUserID extracts the user ID from context, returning ok=false if not found. +func GetUserID(ctx context.Context) (int64, bool) { + val := ctx.Value(userIDKey) + if val == nil { + return 0, false + } + uid, ok := val.(int64) + return uid, ok && uid > 0 +} + +func extractToken(r *http.Request) string { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if authHeader != "" { + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return strings.TrimSpace(authHeader[7:]) + } + return authHeader + } + + if qToken := strings.TrimSpace(r.URL.Query().Get("token")); qToken != "" { + return qToken + } + if qAuth := strings.TrimSpace(r.URL.Query().Get("auth")); qAuth != "" { + return qAuth + } + + return "" +} + +// RequireAuth enforces a valid authentication token. +func RequireAuth(authSvc *services.AuthService) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := extractToken(r) + if token == "" { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + userID, err := authSvc.VerifyToken(token) + if err != nil || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Invalid or expired authentication token") + return + } + + ctx := WithUserID(r.Context(), userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// OptionalAuth parses authentication token if present without rejecting unauthenticated requests. +func OptionalAuth(authSvc *services.AuthService) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := extractToken(r) + if token != "" { + if userID, err := authSvc.VerifyToken(token); err == nil && userID > 0 { + r = r.WithContext(WithUserID(r.Context(), userID)) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a4d275f..5090b39 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,11 +22,13 @@ type Config struct { ApiID int ApiHash string BotToken string + SecretKey string SessionString string ChannelID int64 DumpChannelID int64 } + // Load reads configuration from .env file (if present) and environment variables. func Load() *Config { // Try loading .env; ignore error if not found (e.g., in container/production) @@ -53,6 +55,7 @@ func Load() *Config { ApiID: getEnvInt("API_ID", 0), ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), + SecretKey: strings.TrimSpace(os.Getenv("SECRET_KEY")), SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), ChannelID: getEnvInt64("CHANNEL_ID", 0), DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), diff --git a/internal/models/artist_album.go b/internal/models/artist_album.go new file mode 100644 index 0000000..6522649 --- /dev/null +++ b/internal/models/artist_album.go @@ -0,0 +1,59 @@ +package models + +// Artist represents an artist record in the artists collection. +type Artist struct { + ID string `bson:"_id" json:"_id"` + Name string `bson:"name" json:"name"` + MatchArtist string `bson:"match_artist" json:"match_artist"` + TracksCount int64 `bson:"tracks_count" json:"tracks_count"` + CoverURL string `bson:"cover_url" json:"cover_url"` + Followers int64 `bson:"followers,omitempty" json:"followers"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` +} + +// Album represents an album record in the albums collection. +type Album struct { + ID string `bson:"_id" json:"_id"` + Title string `bson:"title" json:"title"` + Artist string `bson:"artist" json:"artist"` + Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` + CoverURL string `bson:"cover_url" json:"cover_url"` + DurationTotal float64 `bson:"duration_total,omitempty" json:"duration_total"` + MatchAlbum string `bson:"match_album,omitempty" json:"match_album"` + MatchArtist string `bson:"match_artist,omitempty" json:"match_artist"` + TracksCount int64 `bson:"tracks_count" json:"tracks_count"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` +} + +// ArtistDetail contains full artist information along with their top tracks and albums. +type ArtistDetail struct { + Artist *Artist `json:"artist"` + TopTracks []*BrowseItem `json:"top_tracks"` + Albums []*Album `json:"albums"` +} + +// AlbumDetail contains full album metadata and its ordered tracklist. +type AlbumDetail struct { + Album *Album `json:"album"` + Tracks []*BrowseItem `json:"tracks"` +} + +// ArtistsResponse is a paginated list of artists. +type ArtistsResponse struct { + Items []*Artist `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` +} + +// AlbumsResponse is a paginated list of albums. +type AlbumsResponse struct { + Items []*Album `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/models/auth_user.go b/internal/models/auth_user.go new file mode 100644 index 0000000..5b005f7 --- /dev/null +++ b/internal/models/auth_user.go @@ -0,0 +1,45 @@ +package models + +// TelegramInfo stores nested Telegram account information. +type TelegramInfo struct { + ID int64 `bson:"id,omitempty" json:"id,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` +} + +// User represents a user record in the users collection. +type User struct { + ID int64 `bson:"_id" json:"id"` + UserID int64 `bson:"user_id,omitempty" json:"user_id,omitempty"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` + Telegram *TelegramInfo `bson:"telegram,omitempty" json:"telegram,omitempty"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + + +// AuthResponse returns token and user payload upon successful authentication. +type AuthResponse struct { + OK bool `json:"ok"` + Token string `json:"token"` + User *User `json:"user"` +} + +// TelegramWebAppRequest contains raw initData string sent from Telegram WebApp. +type TelegramWebAppRequest struct { + InitData string `json:"init_data"` +} + +// TelegramWidgetRequest contains callback parameters from Telegram Login Widget. +type TelegramWidgetRequest struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + AuthDate int64 `json:"auth_date"` + Hash string `json:"hash"` +} diff --git a/internal/models/favourite_playlist.go b/internal/models/favourite_playlist.go new file mode 100644 index 0000000..55eb05f --- /dev/null +++ b/internal/models/favourite_playlist.go @@ -0,0 +1,83 @@ +package models + +// UserFavourite represents a track liked by a user in the userFavourites collection. +type UserFavourite struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + TrackID string `bson:"track_id" json:"track_id"` + CreatedAt float64 `bson:"created_at" json:"created_at"` +} + +// UserFavouriteArtist represents a followed artist in user_favourite_artists. +type UserFavouriteArtist struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + ArtistID string `bson:"artist_id" json:"artist_id"` + CreatedAt float64 `bson:"created_at" json:"created_at"` +} + +// UserPlaylist represents a custom playlist in the userPlaylists collection. +type UserPlaylist struct { + ID string `bson:"_id" json:"_id"` + UserID int64 `bson:"user_id" json:"user_id"` + Title string `bson:"title" json:"title"` + Description string `bson:"description,omitempty" json:"description"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url"` + TrackCount int64 `bson:"track_count,omitempty" json:"track_count"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` +} + +// PlaylistTrack represents a track association in the playlistTracks collection. +type PlaylistTrack struct { + ID string `bson:"_id" json:"_id"` + PlaylistID string `bson:"playlist_id" json:"playlist_id"` + TrackID string `bson:"track_id" json:"track_id"` + Position int `bson:"position" json:"position"` + AddedAt float64 `bson:"added_at" json:"added_at"` +} + +// PlaylistDetail bundles a playlist with its full track entities. +type PlaylistDetail struct { + Playlist *UserPlaylist `json:"playlist"` + Tracks []*BrowseItem `json:"tracks"` +} + +// PlaylistsResponse is the list of user playlists. +type PlaylistsResponse struct { + OK bool `json:"ok"` + Total int `json:"total"` + Items []*UserPlaylist `json:"items"` +} + +// FavouriteIDsResponse contains array of liked track IDs. +type FavouriteIDsResponse struct { + OK bool `json:"ok"` + IDs []string `json:"ids"` +} + +// FavouriteArtistsResponse contains array of followed artist IDs. +type FavouriteArtistsResponse struct { + OK bool `json:"ok"` + IDs []string `json:"ids"` +} + +// ListeningEventItem represents a single telemetry playback event. +type ListeningEventItem struct { + ID string `json:"id" bson:"id"` + TrackID string `json:"track_id" bson:"track_id"` + PlayedAt float64 `json:"played_at" bson:"played_at"` + StartedAt float64 `json:"started_at" bson:"started_at"` + PlayedMs float64 `json:"played_ms" bson:"played_ms"` + DurationMs float64 `json:"duration_ms" bson:"duration_ms"` + Completed bool `json:"completed" bson:"completed"` + Skipped bool `json:"skipped" bson:"skipped"` + Source string `json:"source" bson:"source"` + SessionID string `json:"session_id,omitempty" bson:"session_id,omitempty"` +} + +// ListeningEventsPayload is the payload for batch listening events. +type ListeningEventsPayload struct { + Events []ListeningEventItem `json:"events"` +} + diff --git a/internal/models/track.go b/internal/models/track.go index a89d6aa..7bab910 100644 --- a/internal/models/track.go +++ b/internal/models/track.go @@ -14,7 +14,9 @@ type AudioMeta struct { Bitrate int `bson:"bitrate,omitempty" json:"bitrate,omitempty"` MimeType string `bson:"mime_type,omitempty" json:"mime_type,omitempty"` FileSize int64 `bson:"file_size,omitempty" json:"file_size,omitempty"` - CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + Lyrics string `bson:"lyrics,omitempty" json:"lyrics,omitempty"` + Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` } // TelegramMeta stores Telegram file references and chat mapping. @@ -36,22 +38,24 @@ type SpotifyMeta struct { // Track represents the full track document stored in the audioTracks MongoDB collection. type Track struct { - ID string `bson:"_id" json:"_id"` - Audio AudioMeta `bson:"audio" json:"audio"` - Telegram TelegramMeta `bson:"telegram" json:"telegram"` - Spotify SpotifyMeta `bson:"spotify,omitempty" json:"spotify,omitempty"` - SourceChatID int64 `bson:"source_chat_id,omitempty" json:"source_chat_id,omitempty"` - SourceMessageID int64 `bson:"source_message_id,omitempty" json:"source_message_id,omitempty"` - CacheChatID int64 `bson:"cache_chat_id,omitempty" json:"cache_chat_id,omitempty"` - CacheMessageID int64 `bson:"cache_message_id,omitempty" json:"cache_message_id,omitempty"` - TopicID int64 `bson:"topic_id,omitempty" json:"topic_id,omitempty"` - TopicName string `bson:"topic_name,omitempty" json:"topic_name,omitempty"` - PlayCount int64 `bson:"play_count,omitempty" json:"play_count"` - LikesCount int64 `bson:"likes_count,omitempty" json:"likes_count"` - CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` - UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` - Deleted bool `bson:"deleted,omitempty" json:"deleted"` - Liked bool `bson:"-" json:"liked"` + ID string `bson:"_id" json:"_id"` + Audio AudioMeta `bson:"audio" json:"audio"` + Telegram TelegramMeta `bson:"telegram" json:"telegram"` + Spotify SpotifyMeta `bson:"spotify,omitempty" json:"spotify,omitempty"` + Lyrics string `bson:"lyrics,omitempty" json:"lyrics,omitempty"` + Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` + SourceChatID int64 `bson:"source_chat_id,omitempty" json:"source_chat_id,omitempty"` + SourceMessageID int64 `bson:"source_message_id,omitempty" json:"source_message_id,omitempty"` + CacheChatID int64 `bson:"cache_chat_id,omitempty" json:"cache_chat_id,omitempty"` + CacheMessageID int64 `bson:"cache_message_id,omitempty" json:"cache_message_id,omitempty"` + TopicID int64 `bson:"topic_id,omitempty" json:"topic_id,omitempty"` + TopicName string `bson:"topic_name,omitempty" json:"topic_name,omitempty"` + PlayCount int64 `bson:"play_count,omitempty" json:"play_count"` + LikesCount int64 `bson:"likes_count,omitempty" json:"likes_count"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` + Deleted bool `bson:"deleted,omitempty" json:"deleted"` + Liked bool `bson:"-" json:"liked"` } // EffectiveCoverURL returns the best available cover art URL. @@ -70,26 +74,37 @@ func (t *Track) EffectiveArtist() string { return t.Audio.Performer } +// EffectiveTitles returns the best available alternative titles map (original, romanized, etc.). +func (t *Track) EffectiveTitles() map[string]any { + if len(t.Titles) > 0 { + return t.Titles + } + if len(t.Audio.Titles) > 0 { + return t.Audio.Titles + } + return nil +} + // BrowseItem represents a flattened track item optimized for list/feed UI views. type BrowseItem struct { - ID string `json:"_id"` - SourceChatID int64 `json:"source_chat_id,omitempty"` - SourceMessageID int64 `json:"source_message_id,omitempty"` - TopicID int64 `json:"topic_id,omitempty"` - TopicName string `json:"topic_name,omitempty"` - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album,omitempty"` - AlbumID string `json:"album_id,omitempty"` - DurationSec float64 `json:"duration_sec"` - Type string `json:"type,omitempty"` - SamplingRateHz int `json:"sampling_rate_hz,omitempty"` - SpotifyURL string `json:"spotify_url,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - Titles []string `json:"titles,omitempty"` - CreatedAt float64 `json:"created_at"` - UpdatedAt float64 `json:"updated_at"` - Liked bool `json:"liked"` + ID string `json:"_id"` + SourceChatID int64 `json:"source_chat_id,omitempty"` + SourceMessageID int64 `json:"source_message_id,omitempty"` + TopicID int64 `json:"topic_id,omitempty"` + TopicName string `json:"topic_name,omitempty"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album,omitempty"` + AlbumID string `json:"album_id,omitempty"` + DurationSec float64 `json:"duration_sec"` + Type string `json:"type,omitempty"` + SamplingRateHz int `json:"sampling_rate_hz,omitempty"` + SpotifyURL string `json:"spotify_url,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Titles map[string]any `json:"titles,omitempty"` + CreatedAt float64 `json:"created_at"` + UpdatedAt float64 `json:"updated_at"` + Liked bool `json:"liked"` } // ToBrowseItem converts a full Track entity into a lightweight BrowseItem. @@ -109,6 +124,7 @@ func (t *Track) ToBrowseItem() *BrowseItem { SamplingRateHz: t.Audio.SamplingRateHz, SpotifyURL: t.Spotify.URL, CoverURL: t.EffectiveCoverURL(), + Titles: t.EffectiveTitles(), CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, Liked: t.Liked, diff --git a/internal/repository/artist_album_repo.go b/internal/repository/artist_album_repo.go new file mode 100644 index 0000000..ee1d7f8 --- /dev/null +++ b/internal/repository/artist_album_repo.go @@ -0,0 +1,231 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// ArtistAlbumRepository manages data access for artists and albums. +type ArtistAlbumRepository interface { + ListArtists(ctx context.Context, page, perPage int) ([]*models.Artist, int64, error) + GetArtistByID(ctx context.Context, id string) (*models.Artist, error) + GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) + GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) + + ListAlbums(ctx context.Context, page, perPage int, artistFilter string) ([]*models.Album, int64, error) + GetAlbumByID(ctx context.Context, id string) (*models.Album, error) + GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) +} + +type mongoArtistAlbumRepository struct { + artistsCol *mongo.Collection + albumsCol *mongo.Collection + tracksCol *mongo.Collection +} + +// NewArtistAlbumRepository creates an ArtistAlbumRepository backed by MongoDB. +func NewArtistAlbumRepository(db *database.Client) ArtistAlbumRepository { + return &mongoArtistAlbumRepository{ + artistsCol: db.Collection("artists"), + albumsCol: db.Collection("albums"), + tracksCol: db.Collection("audioTracks"), + } +} + +func (r *mongoArtistAlbumRepository) ListArtists(ctx context.Context, page, perPage int) ([]*models.Artist, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + filter := bson.M{"tracks_count": bson.M{"$gt": 0}} + + total, err := r.artistsCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("failed to count artists: %w", err) + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{{Key: "tracks_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.artistsCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("failed to list artists: %w", err) + } + defer cursor.Close(ctx) + + var artists []*models.Artist + if err := cursor.All(ctx, &artists); err != nil { + return nil, 0, fmt.Errorf("failed to decode artists: %w", err) + } + + return artists, total, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistByID(ctx context.Context, id string) (*models.Artist, error) { + id = strings.TrimSpace(id) + filter := bson.M{"$or": []bson.M{{"_id": id}, {"match_artist": strings.ToLower(id)}}} + + var artist models.Artist + err := r.artistsCol.FindOne(ctx, filter).Decode(&artist) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch artist %s: %w", id, err) + } + + return &artist, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + escaped := regexp.QuoteMeta(artistName) + regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.artist": regex}, + {"audio.artists": regex}, + {"audio.performer": regex}, + }, + } + + opts := options.Find(). + SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetLimit(int64(limit)) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, err + } + return tracks, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) { + escaped := regexp.QuoteMeta(artistName) + regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} + + filter := bson.M{ + "$or": []bson.M{ + {"artist": regex}, + {"artists": regex}, + {"match_artist": strings.ToLower(artistName)}, + }, + } + + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). + SetLimit(50) + + cursor, err := r.albumsCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var albums []*models.Album + if err := cursor.All(ctx, &albums); err != nil { + return nil, err + } + return albums, nil +} + +func (r *mongoArtistAlbumRepository) ListAlbums(ctx context.Context, page, perPage int, artistFilter string) ([]*models.Album, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + filter := bson.M{} + if artistFilter != "" { + escaped := regexp.QuoteMeta(artistFilter) + filter["artist"] = bson.M{"$regex": escaped, "$options": "i"} + } + + total, err := r.albumsCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.albumsCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var albums []*models.Album + if err := cursor.All(ctx, &albums); err != nil { + return nil, 0, err + } + return albums, total, nil +} + +func (r *mongoArtistAlbumRepository) GetAlbumByID(ctx context.Context, id string) (*models.Album, error) { + id = strings.TrimSpace(id) + var album models.Album + err := r.albumsCol.FindOne(ctx, bson.M{"_id": id}).Decode(&album) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &album, nil +} + +func (r *mongoArtistAlbumRepository) GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) { + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": albumID, + } + + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: 1}}). + SetLimit(100) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, err + } + return tracks, nil +} diff --git a/internal/repository/favourite_playlist_repo.go b/internal/repository/favourite_playlist_repo.go new file mode 100644 index 0000000..76d7f52 --- /dev/null +++ b/internal/repository/favourite_playlist_repo.go @@ -0,0 +1,506 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// FavouritePlaylistRepository defines data operations for favourites, playlists, and history. +type FavouritePlaylistRepository interface { + // Track Favourites + AddFavourite(ctx context.Context, userID int64, trackID string) (alreadyExists bool, err error) + RemoveFavourite(ctx context.Context, userID int64, trackID string) (deleted bool, err error) + GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) + GetFavourites(ctx context.Context, userID int64, page, limit int) ([]*models.UserFavourite, int64, *float64, error) + + // Artist Favourites + AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (alreadyExists bool, err error) + RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (deleted bool, err error) + GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) + + // Playlists + CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error + GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) + GetPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) + UpdatePlaylist(ctx context.Context, playlistID string, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) + DeletePlaylist(ctx context.Context, playlistID string, userID int64) (bool, error) + AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error + RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error + GetPlaylistTrackIDs(ctx context.Context, playlistID string) ([]string, error) + ReorderPlaylistTracks(ctx context.Context, playlistID string, trackIDs []string) error + + // History & Listening Events + RecordHistory(ctx context.Context, userID int64, trackID string, playedAt float64) error + GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) + RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error +} + +type mongoFavouritePlaylistRepository struct { + favCol *mongo.Collection + favArtistCol *mongo.Collection + artistsCol *mongo.Collection + playlistsCol *mongo.Collection + playlistTrkCol *mongo.Collection + historyCol *mongo.Collection + eventsCol *mongo.Collection +} + +// NewFavouritePlaylistRepository returns an instance of FavouritePlaylistRepository. +func NewFavouritePlaylistRepository(db *database.Client) FavouritePlaylistRepository { + return &mongoFavouritePlaylistRepository{ + favCol: db.Collection("userFavourites"), + favArtistCol: db.Collection("user_favourite_artists"), + artistsCol: db.Collection("artists"), + playlistsCol: db.Collection("userPlaylists"), + playlistTrkCol: db.Collection("playlistTracks"), + historyCol: db.Collection("userHistory"), + eventsCol: db.Collection("user_listening_events"), + } +} + +// AddFavourite adds a track to user favourites. +func (r *mongoFavouritePlaylistRepository) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"user_id": userID, "track_id": trackID} + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": bson.M{"user_id": userID, "track_id": trackID, "updated_at": now}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.favCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return false, fmt.Errorf("failed to add favourite: %w", err) + } + alreadyExists := res.UpsertedID == nil + return alreadyExists, nil +} + +// RemoveFavourite deletes a track from user favourites. +func (r *mongoFavouritePlaylistRepository) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + filter := bson.M{"user_id": userID, "track_id": trackID} + res, err := r.favCol.DeleteOne(ctx, filter) + if err != nil { + return false, fmt.Errorf("failed to remove favourite: %w", err) + } + return res.DeletedCount > 0, nil +} + +// GetFavouriteTrackIDs returns paginated track IDs liked by the user. +func (r *mongoFavouritePlaylistRepository) GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > 1000 { + limit = 200 + } + filter := bson.M{"user_id": userID} + total, err := r.favCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, nil, fmt.Errorf("failed to count favourites: %w", err) + } + + skip := int64((page - 1) * limit) + opts := options.Find(). + SetProjection(bson.M{"_id": 0, "track_id": 1, "created_at": 1, "updated_at": 1}). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(limit)) + + cursor, err := r.favCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, nil, fmt.Errorf("failed to query favourite track IDs: %w", err) + } + defer cursor.Close(ctx) + + var ids []string + var maxTS *float64 + for cursor.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + CreatedAt *float64 `bson:"created_at"` + UpdatedAt *float64 `bson:"updated_at"` + } + if err := cursor.Decode(&doc); err != nil { + continue + } + if doc.TrackID != "" { + ids = append(ids, doc.TrackID) + } + ts := doc.UpdatedAt + if ts == nil { + ts = doc.CreatedAt + } + if ts != nil { + if maxTS == nil || *ts > *maxTS { + t := *ts + maxTS = &t + } + } + } + + return ids, total, maxTS, nil +} + +// GetFavourites returns paginated user favourite entries. +func (r *mongoFavouritePlaylistRepository) GetFavourites(ctx context.Context, userID int64, page, limit int) ([]*models.UserFavourite, int64, *float64, error) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + filter := bson.M{"user_id": userID} + total, err := r.favCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, nil, err + } + + skip := int64((page - 1) * limit) + opts := options.Find(). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(limit)) + + cursor, err := r.favCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, nil, err + } + defer cursor.Close(ctx) + + var items []*models.UserFavourite + var maxTS *float64 + for cursor.Next(ctx) { + var fav models.UserFavourite + if err := cursor.Decode(&fav); err != nil { + continue + } + items = append(items, &fav) + if maxTS == nil || fav.CreatedAt > *maxTS { + t := fav.CreatedAt + maxTS = &t + } + } + return items, total, maxTS, nil +} + +// AddFavouriteArtist adds an artist to user favourites and updates follower count. +func (r *mongoFavouritePlaylistRepository) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"user_id": userID, "artist_id": artistID} + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": bson.M{"user_id": userID, "artist_id": artistID, "updated_at": now}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.favArtistCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return false, err + } + alreadyExists := res.UpsertedID == nil + if !alreadyExists { + _, _ = r.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, bson.M{"$inc": bson.M{"followers": 1}}) + } + return alreadyExists, nil +} + +// RemoveFavouriteArtist removes an artist from favourites and decrements follower count. +func (r *mongoFavouritePlaylistRepository) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + filter := bson.M{"user_id": userID, "artist_id": artistID} + res, err := r.favArtistCol.DeleteOne(ctx, filter) + if err != nil { + return false, err + } + if res.DeletedCount > 0 { + _, _ = r.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, bson.M{"$inc": bson.M{"followers": -1}}) + return true, nil + } + return false, nil +} + +// GetFavouriteArtistIDs returns list of followed artist IDs for a user. +func (r *mongoFavouritePlaylistRepository) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { + opts := options.Find().SetProjection(bson.M{"_id": 0, "artist_id": 1}) + cursor, err := r.favArtistCol.Find(ctx, bson.M{"user_id": userID}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var ids []string + for cursor.Next(ctx) { + var doc struct { + ArtistID string `bson:"artist_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.ArtistID != "" { + ids = append(ids, doc.ArtistID) + } + } + return ids, nil +} + +// CreatePlaylist stores a new custom playlist. +func (r *mongoFavouritePlaylistRepository) CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error { + now := float64(time.Now().Unix()) + if p.CreatedAt == 0 { + p.CreatedAt = now + } + p.UpdatedAt = now + _, err := r.playlistsCol.InsertOne(ctx, p) + return err +} + +// GetUserPlaylists lists all playlists owned by a user. +func (r *mongoFavouritePlaylistRepository) GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) { + opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}) + cursor, err := r.playlistsCol.Find(ctx, bson.M{"user_id": userID}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.UserPlaylist + if err := cursor.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +// GetPlaylistByID fetches a playlist document by ID. +func (r *mongoFavouritePlaylistRepository) GetPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { + var p models.UserPlaylist + err := r.playlistsCol.FindOne(ctx, bson.M{"_id": playlistID}).Decode(&p) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +// UpdatePlaylist updates title, description, or cover of a playlist. +func (r *mongoFavouritePlaylistRepository) UpdatePlaylist(ctx context.Context, playlistID string, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) { + now := float64(time.Now().Unix()) + update := bson.M{ + "$set": bson.M{ + "updated_at": now, + }, + } + setFields := update["$set"].(bson.M) + if title != "" { + setFields["title"] = title + } + if description != "" { + setFields["description"] = description + } + if coverURL != "" { + setFields["cover_url"] = coverURL + } + + filter := bson.M{"_id": playlistID, "user_id": userID} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var updated models.UserPlaylist + err := r.playlistsCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &updated, nil +} + +// DeletePlaylist deletes a playlist and all its associated track links. +func (r *mongoFavouritePlaylistRepository) DeletePlaylist(ctx context.Context, playlistID string, userID int64) (bool, error) { + filter := bson.M{"_id": playlistID, "user_id": userID} + res, err := r.playlistsCol.DeleteOne(ctx, filter) + if err != nil { + return false, err + } + if res.DeletedCount > 0 { + _, _ = r.playlistTrkCol.DeleteMany(ctx, bson.M{"playlist_id": playlistID}) + return true, nil + } + return false, nil +} + +// AddTracksToPlaylist inserts tracks into playlistTracks and updates playlist track_count. +func (r *mongoFavouritePlaylistRepository) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error { + if len(trackIDs) == 0 { + return nil + } + now := float64(time.Now().Unix()) + + // Find current max position + opts := options.FindOne().SetSort(bson.D{{Key: "position", Value: -1}}).SetProjection(bson.M{"position": 1}) + var lastDoc struct { + Position int `bson:"position"` + } + pos := 0 + if err := r.playlistTrkCol.FindOne(ctx, bson.M{"playlist_id": playlistID}, opts).Decode(&lastDoc); err == nil { + pos = lastDoc.Position + 1 + } + + for _, tid := range trackIDs { + id := fmt.Sprintf("%s_%s", playlistID, tid) + pt := models.PlaylistTrack{ + ID: id, + PlaylistID: playlistID, + TrackID: tid, + Position: pos, + AddedAt: now, + } + _, err := r.playlistTrkCol.UpdateOne( + ctx, + bson.M{"_id": id}, + bson.M{"$set": pt}, + options.UpdateOne().SetUpsert(true), + ) + if err != nil { + return err + } + pos++ + } + + count, _ := r.playlistTrkCol.CountDocuments(ctx, bson.M{"playlist_id": playlistID}) + _, _ = r.playlistsCol.UpdateOne(ctx, bson.M{"_id": playlistID}, bson.M{"$set": bson.M{"track_count": count, "updated_at": now}}) + return nil +} + +// RemoveTrackFromPlaylist deletes a track relation from playlist. +func (r *mongoFavouritePlaylistRepository) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { + id := fmt.Sprintf("%s_%s", playlistID, trackID) + _, err := r.playlistTrkCol.DeleteOne(ctx, bson.M{"$or": []bson.M{{"_id": id}, {"playlist_id": playlistID, "track_id": trackID}}}) + if err != nil { + return err + } + now := float64(time.Now().Unix()) + count, _ := r.playlistTrkCol.CountDocuments(ctx, bson.M{"playlist_id": playlistID}) + _, _ = r.playlistsCol.UpdateOne(ctx, bson.M{"_id": playlistID}, bson.M{"$set": bson.M{"track_count": count, "updated_at": now}}) + return nil +} + +// GetPlaylistTrackIDs returns ordered track IDs for a playlist. +func (r *mongoFavouritePlaylistRepository) GetPlaylistTrackIDs(ctx context.Context, playlistID string) ([]string, error) { + opts := options.Find(). + SetSort(bson.D{{Key: "position", Value: 1}, {Key: "added_at", Value: 1}}). + SetProjection(bson.M{"track_id": 1}) + + cursor, err := r.playlistTrkCol.Find(ctx, bson.M{"playlist_id": playlistID}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var ids []string + for cursor.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + } + } + return ids, nil +} + +// ReorderPlaylistTracks updates the position of tracks in the playlist according to trackIDs order. +func (r *mongoFavouritePlaylistRepository) ReorderPlaylistTracks(ctx context.Context, playlistID string, trackIDs []string) error { + now := float64(time.Now().Unix()) + for i, tid := range trackIDs { + id := fmt.Sprintf("%s_%s", playlistID, tid) + _, _ = r.playlistTrkCol.UpdateOne( + ctx, + bson.M{"$or": []bson.M{{"_id": id}, {"playlist_id": playlistID, "track_id": tid}}}, + bson.M{"$set": bson.M{"position": i}}, + ) + } + _, _ = r.playlistsCol.UpdateOne(ctx, bson.M{"_id": playlistID}, bson.M{"$set": bson.M{"updated_at": now}}) + return nil +} + +// RecordHistory stores an entry in userHistory. +func (r *mongoFavouritePlaylistRepository) RecordHistory(ctx context.Context, userID int64, trackID string, playedAt float64) error { + if playedAt == 0 { + playedAt = float64(time.Now().Unix()) + } + doc := bson.M{ + "user_id": userID, + "track_id": trackID, + "played_at": playedAt, + } + _, err := r.historyCol.InsertOne(ctx, doc) + return err +} + +// GetUserHistoryTrackIDs returns recent unique played track IDs. +func (r *mongoFavouritePlaylistRepository) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + opts := options.Find(). + SetSort(bson.D{{Key: "played_at", Value: -1}}). + SetLimit(int64(limit * 2)). + SetProjection(bson.M{"track_id": 1}) + + filter := bson.M{"$or": []bson.M{{"user_id": userID}, {"user_id": fmt.Sprintf("%d", userID)}}} + cursor, err := r.historyCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + seen := make(map[string]bool) + var ids []string + for cursor.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { + if !seen[doc.TrackID] { + seen[doc.TrackID] = true + ids = append(ids, doc.TrackID) + if len(ids) >= limit { + break + } + } + } + } + return ids, nil +} + +// RecordListeningEvents saves a batch of telemetry listening events into user_listening_events. +func (r *mongoFavouritePlaylistRepository) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + if len(events) == 0 { + return nil + } + var docs []any + for _, ev := range events { + d := bson.M{ + "id": ev.ID, + "user_id": userID, + "track_id": ev.TrackID, + "played_at": ev.PlayedAt, + "started_at": ev.StartedAt, + "played_ms": ev.PlayedMs, + "duration_ms": ev.DurationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": ev.Source, + "session_id": ev.SessionID, + "created_at": float64(time.Now().Unix()), + } + docs = append(docs, d) + } + _, err := r.eventsCol.InsertMany(ctx, docs) + return err +} diff --git a/internal/repository/track_repo.go b/internal/repository/track_repo.go index 4ae0bb8..17565e6 100644 --- a/internal/repository/track_repo.go +++ b/internal/repository/track_repo.go @@ -18,6 +18,7 @@ import ( // TrackRepository defines the data access contract for tracks and related metadata. type TrackRepository interface { GetByID(ctx context.Context, id string) (*models.Track, error) + GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) @@ -26,6 +27,7 @@ type TrackRepository interface { IncrementPlayCount(ctx context.Context, id string) error } + type mongoTrackRepository struct { db *database.Client col *mongo.Collection @@ -62,6 +64,44 @@ func (r *mongoTrackRepository) GetByID(ctx context.Context, id string) (*models. return &track, nil } +func (r *mongoTrackRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) { + if len(ids) == 0 { + return []*models.Track{}, nil + } + + filter := bson.M{ + "_id": bson.M{"$in": ids}, + "deleted": bson.M{"$ne": true}, + } + + cursor, err := r.col.Find(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to query tracks by IDs: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, fmt.Errorf("failed to decode tracks: %w", err) + } + + // Preserve the requested order of IDs + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + ordered := make([]*models.Track, 0, len(ids)) + for _, id := range ids { + if t, ok := trackMap[id]; ok { + ordered = append(ordered, t) + } + } + + return ordered, nil +} + + func (r *mongoTrackRepository) List( ctx context.Context, page, perPage int, @@ -136,9 +176,15 @@ func (r *mongoTrackRepository) Search(ctx context.Context, query string, limit i {"audio.artist": regexPattern}, {"audio.performer": regexPattern}, {"audio.album": regexPattern}, + {"titles.romanized": regexPattern}, + {"titles.original": regexPattern}, + {"titles.translations.en": regexPattern}, + {"audio.titles.romanized": regexPattern}, + {"audio.titles.original": regexPattern}, }, } + opts := options.Find(). SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). SetLimit(int64(limit)) diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go new file mode 100644 index 0000000..4a0e6ca --- /dev/null +++ b/internal/repository/user_repo.go @@ -0,0 +1,84 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// UserRepository manages database operations for users. +type UserRepository interface { + UpsertUser(ctx context.Context, user *models.User) (*models.User, error) + GetUserByID(ctx context.Context, id int64) (*models.User, error) +} + +type mongoUserRepository struct { + usersCol *mongo.Collection +} + +// NewUserRepository creates a UserRepository instance. +func NewUserRepository(db *database.Client) UserRepository { + return &mongoUserRepository{ + usersCol: db.Collection("users"), + } +} + +// UpsertUser inserts or updates a user document based on user ID. +func (r *mongoUserRepository) UpsertUser(ctx context.Context, u *models.User) (*models.User, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": u.ID} + + update := bson.M{ + "$set": bson.M{ + "first_name": u.FirstName, + "username": u.Username, + "photo_url": u.PhotoURL, + "profile_url": u.ProfileURL, + "updated_at": now, + }, + "$setOnInsert": bson.M{ + "created_at": now, + }, + } + + if u.Telegram != nil { + update["$set"].(bson.M)["telegram"] = u.Telegram + } + if u.Status != "" { + update["$set"].(bson.M)["status"] = u.Status + } + + opts := options.FindOneAndUpdate(). + SetUpsert(true). + SetReturnDocument(options.After) + + var result models.User + err := r.usersCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&result) + if err != nil { + return nil, fmt.Errorf("failed to upsert user %d: %w", u.ID, err) + } + + return &result, nil +} + +// GetUserByID fetches a user by Telegram numeric ID. +func (r *mongoUserRepository) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + filter := bson.M{"_id": id} + var u models.User + err := r.usersCol.FindOne(ctx, filter).Decode(&u) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch user %d: %w", id, err) + } + return &u, nil +} diff --git a/internal/server/docs.go b/internal/server/docs.go index ba6af48..5f23791 100644 --- a/internal/server/docs.go +++ b/internal/server/docs.go @@ -8,7 +8,7 @@ const openAPISpecJSON = `{ "openapi": "3.0.3", "info": { "title": "StreamGO API", - "description": "High-Performance Media Streaming and Telegram Bot Backend written in Go", + "description": "High-Performance Media Streaming, Discovery, and Telegram MiniApp Backend written in Go", "version": "1.0.0" }, "servers": [ @@ -17,15 +17,22 @@ const openAPISpecJSON = `{ "description": "Current Server" } ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT / v1.." + } + } + }, "paths": { "/": { "get": { "tags": ["System"], "summary": "Root Service Info", "description": "Returns basic service metadata, status, and running version.", - "responses": { - "200": { "description": "Successful Response" } - } + "responses": { "200": { "description": "Successful Response" } } } }, "/health": { @@ -33,9 +40,7 @@ const openAPISpecJSON = `{ "tags": ["System"], "summary": "Health Check", "description": "Returns server uptime, current time, Telegram client state, and real-time MongoDB connectivity status.", - "responses": { - "200": { "description": "Health status response" } - } + "responses": { "200": { "description": "Health status response" } } } }, "/tracks": { @@ -50,9 +55,7 @@ const openAPISpecJSON = `{ { "name": "topic", "in": "query", "schema": { "type": "string" } }, { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } ], - "responses": { - "200": { "description": "Paginated list of browse items" } - } + "responses": { "200": { "description": "Paginated list of browse items" } } } }, "/tracks/{id}": { @@ -76,9 +79,7 @@ const openAPISpecJSON = `{ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } ], - "responses": { - "200": { "description": "Randomized selection of tracks" } - } + "responses": { "200": { "description": "Randomized selection of tracks" } } } }, "/search": { @@ -90,9 +91,7 @@ const openAPISpecJSON = `{ { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }, { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } ], - "responses": { - "200": { "description": "Search results matching query" } - } + "responses": { "200": { "description": "Search results matching query" } } } }, "/topics": { @@ -103,9 +102,7 @@ const openAPISpecJSON = `{ "parameters": [ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100 } } ], - "responses": { - "200": { "description": "Aggregated topic list" } - } + "responses": { "200": { "description": "Aggregated topic list" } } } }, "/topics/{name}/tracks": { @@ -117,18 +114,14 @@ const openAPISpecJSON = `{ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } } ], - "responses": { - "200": { "description": "Paginated tracks belonging to topic" } - } + "responses": { "200": { "description": "Paginated tracks belonging to topic" } } } }, "/channelids": { "get": { "tags": ["Topics"], "summary": "Get Indexed Channel IDs", - "responses": { - "200": { "description": "List of indexed Telegram source channels" } - } + "responses": { "200": { "description": "List of indexed Telegram source channels" } } } }, "/tracks/{id}/stream": { @@ -153,8 +146,347 @@ const openAPISpecJSON = `{ "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], + "responses": { "200": { "description": "Audio headers available" } } + } + }, + "/artists": { + "get": { + "tags": ["Artists"], + "summary": "List Artists", + "description": "Paginated directory of artists indexed from indexed tracks.", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } } + ], + "responses": { "200": { "description": "Paginated list of artists" } } + } + }, + "/artists/{id}": { + "get": { + "tags": ["Artists"], + "summary": "Get Artist Details", + "description": "Returns artist profile, follower count, top tracks, and discography.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Artist detail with top tracks and albums" }, + "404": { "description": "Artist not found" } + } + } + }, + "/artists/{id}/tracks": { + "get": { + "tags": ["Artists"], + "summary": "Get Artist Tracks", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Top tracks for artist" } } + } + }, + "/albums": { + "get": { + "tags": ["Albums"], + "summary": "List Albums", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "artist", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Paginated album list" } } + } + }, + "/albums/{id}": { + "get": { + "tags": ["Albums"], + "summary": "Get Album Details", + "description": "Returns album metadata and complete tracklist.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Album details and ordered tracks" }, + "404": { "description": "Album not found" } + } + } + }, + "/albums/{id}/tracks": { + "get": { + "tags": ["Albums"], + "summary": "Get Album Tracks", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Album tracks" } } + } + }, + "/favourites": { + "get": { + "tags": ["Favourites"], + "summary": "List User Favourites", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } } + ], + "responses": { "200": { "description": "User favorite tracks" } } + }, + "post": { + "tags": ["Favourites"], + "summary": "Add Track to Favourites", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["track_id"], + "properties": { "track_id": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Favourite saved" } } + } + }, + "/favourites/{id}": { + "delete": { + "tags": ["Favourites"], + "summary": "Remove Track from Favourites", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Removed from favourites" } } + } + }, + "/favourites/ids": { + "get": { + "tags": ["Favourites"], + "summary": "Get Favourite Track IDs", + "security": [{ "bearerAuth": [] }], + "responses": { "200": { "description": "List of liked track IDs" } } + } + }, + "/history": { + "get": { + "tags": ["Favourites"], + "summary": "Get Listening History", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Recent listening history" } } + } + }, + "/listening-events": { + "post": { + "tags": ["Favourites"], + "summary": "Record Playback Telemetry Events", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "events": { "type": "array", "items": { "type": "object" } } } + } + } + } + }, + "responses": { "200": { "description": "Events recorded" } } + } + }, + "/playlists": { + "get": { + "tags": ["Playlists"], + "summary": "List User Playlists", + "security": [{ "bearerAuth": [] }], + "responses": { "200": { "description": "List of playlists owned by user" } } + }, + "post": { + "tags": ["Playlists"], + "summary": "Create Custom Playlist", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["title"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "cover_url": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Created playlist" } } + } + }, + "/playlists/{id}": { + "get": { + "tags": ["Playlists"], + "summary": "Get Playlist by ID", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist detail with tracks" } } + }, + "patch": { + "tags": ["Playlists"], + "summary": "Update Playlist Metadata", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Updated playlist" } } + }, + "delete": { + "tags": ["Playlists"], + "summary": "Delete Playlist", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist deleted" } } + } + }, + "/playlists/{id}/tracks": { + "get": { + "tags": ["Playlists"], + "summary": "Get Playlist Tracks", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Ordered tracks in playlist" } } + }, + "post": { + "tags": ["Playlists"], + "summary": "Add Tracks to Playlist", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "track_id": { "type": "string" }, + "track_ids": { "type": "array", "items": { "type": "string" } } + } + } + } + } + }, + "responses": { "200": { "description": "Tracks added" } } + } + }, + "/playlists/{id}/reorder": { + "post": { + "tags": ["Playlists"], + "summary": "Reorder Playlist Tracks", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["track_ids"], + "properties": { "track_ids": { "type": "array", "items": { "type": "string" } } } + } + } + } + }, + "responses": { "200": { "description": "Playlist tracks reordered" } } + } + }, + "/auth/telegram": { + "post": { + "tags": ["Authentication"], + "summary": "Telegram WebApp Login", + "description": "Verifies Telegram initData HMAC-SHA256 signature and returns user session token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["init_data"], + "properties": { "init_data": { "type": "string" } } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["init_data"], + "properties": { "init_data": { "type": "string" } } + } + } + } + }, + "responses": { + "200": { "description": "Authentication successful" }, + "401": { "description": "Invalid Telegram signature" } + } + } + }, + "/auth/telegram/widget": { + "post": { + "tags": ["Authentication"], + "summary": "Telegram Widget Login", + "description": "Authenticates user using Telegram OAuth Login Widget callback data.", + "responses": { "200": { "description": "Authentication successful" } } + } + }, + "/auth/me": { + "get": { + "tags": ["Authentication"], + "summary": "Current User Profile", + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { "description": "Current user profile" }, + "401": { "description": "Unauthorized" } + } + } + }, + "/cover/{id}": { + "get": { + "tags": ["Media"], + "summary": "Track Cover Redirect", + "description": "Redirects to the best available album artwork or thumbnail for the track.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "302": { "description": "Redirect to cover image URL" } } + } + }, + "/tracks/{id}/lyrics": { + "get": { + "tags": ["Media"], + "summary": "Track Lyrics", + "description": "Returns synchronized or plain lyrics for a track in JSON or text/plain.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["plain", "json"] } } + ], "responses": { - "200": { "description": "Audio headers available" } + "200": { "description": "Lyrics content" }, + "404": { "description": "Lyrics not found" } } } } diff --git a/internal/server/router.go b/internal/server/router.go index d30d6fc..302b4ca 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -56,7 +56,7 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server r.Use(cors.Handler(cors.Options{ AllowedOrigins: corsOrigins, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Range"}, ExposedHeaders: []string{"Link", "Content-Length", "Content-Range", "Accept-Ranges"}, AllowCredentials: true, @@ -78,22 +78,46 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server // Mount Domain Routes when MongoDB is available if db != nil { + // Repositories trackRepo := repository.NewTrackRepository(db) + artistAlbumRepo := repository.NewArtistAlbumRepository(db) + favRepo := repository.NewFavouritePlaylistRepository(db) + userRepo := repository.NewUserRepository(db) + + // Services trackSvc := services.NewTrackService(trackRepo) streamSvc := services.NewStreamService(trackRepo, tg) + artistAlbumSvc := services.NewArtistAlbumService(artistAlbumRepo, trackRepo) + favPlaylistSvc := services.NewFavouritePlaylistService(favRepo, trackRepo) + authSvc := services.NewAuthService(cfg, userRepo) + // Handlers trackHandler := handlers.NewTrackHandler(trackSvc) topicHandler := handlers.NewTopicHandler(trackSvc) streamHandler := handlers.NewStreamHandler(streamSvc) - + artistHandler := handlers.NewArtistHandler(artistAlbumSvc) + albumHandler := handlers.NewAlbumHandler(artistAlbumSvc) + favHandler := handlers.NewFavouriteHandler(favPlaylistSvc, authSvc) + playlistHandler := handlers.NewPlaylistHandler(favPlaylistSvc, authSvc) + authHandler := handlers.NewAuthHandler(cfg, authSvc) + mediaExtraHandler := handlers.NewMediaExtraHandler(trackSvc) + + // Register routes trackHandler.Routes(r) topicHandler.Routes(r) streamHandler.Routes(r) + artistHandler.Routes(r) + albumHandler.Routes(r) + favHandler.Routes(r) + playlistHandler.Routes(r) + authHandler.Routes(r) + mediaExtraHandler.Routes(r) } return s } + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]interface{}{ "service": "StreamGO", diff --git a/internal/services/artist_album_service.go b/internal/services/artist_album_service.go new file mode 100644 index 0000000..b1b97c8 --- /dev/null +++ b/internal/services/artist_album_service.go @@ -0,0 +1,173 @@ +package services + +import ( + "context" + "math" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// ArtistAlbumService orchestrates artist and album operations. +type ArtistAlbumService struct { + repo repository.ArtistAlbumRepository + trackRepo repository.TrackRepository +} + +// NewArtistAlbumService creates a new ArtistAlbumService. +func NewArtistAlbumService(repo repository.ArtistAlbumRepository, trackRepo repository.TrackRepository) *ArtistAlbumService { + return &ArtistAlbumService{ + repo: repo, + trackRepo: trackRepo, + } +} + +// ListArtists returns a paginated list of artists. +func (s *ArtistAlbumService) ListArtists(ctx context.Context, page, perPage int) (*models.ArtistsResponse, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + artists, total, err := s.repo.ListArtists(ctx, page, perPage) + if err != nil { + return nil, err + } + + totalPages := 0 + if total > 0 { + totalPages = int(math.Ceil(float64(total) / float64(perPage))) + } + + return &models.ArtistsResponse{ + Items: artists, + Total: total, + Page: page, + PerPage: perPage, + TotalPages: totalPages, + }, nil +} + +// GetArtistDetail returns detailed artist information with top tracks and albums. +func (s *ArtistAlbumService) GetArtistDetail(ctx context.Context, id string) (*models.ArtistDetail, error) { + artist, err := s.repo.GetArtistByID(ctx, id) + if err != nil { + return nil, err + } + if artist == nil { + return nil, nil + } + + tracks, err := s.repo.GetArtistTracks(ctx, artist.Name, 50) + if err != nil { + return nil, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + + albums, err := s.repo.GetArtistAlbums(ctx, artist.Name) + if err != nil { + return nil, err + } + + return &models.ArtistDetail{ + Artist: artist, + TopTracks: browseItems, + Albums: albums, + }, nil +} + +// GetArtistTracks returns the top tracks for a specific artist ID. +func (s *ArtistAlbumService) GetArtistTracks(ctx context.Context, id string, limit int) ([]*models.BrowseItem, error) { + artist, err := s.repo.GetArtistByID(ctx, id) + if err != nil { + return nil, err + } + if artist == nil { + return nil, nil + } + + tracks, err := s.repo.GetArtistTracks(ctx, artist.Name, limit) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + return items, nil +} + +// ListAlbums returns a paginated list of albums. +func (s *ArtistAlbumService) ListAlbums(ctx context.Context, page, perPage int, artistFilter string) (*models.AlbumsResponse, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + albums, total, err := s.repo.ListAlbums(ctx, page, perPage, artistFilter) + if err != nil { + return nil, err + } + + totalPages := 0 + if total > 0 { + totalPages = int(math.Ceil(float64(total) / float64(perPage))) + } + + return &models.AlbumsResponse{ + Items: albums, + Total: total, + Page: page, + PerPage: perPage, + TotalPages: totalPages, + }, nil +} + +// GetAlbumDetail returns full album metadata along with its tracklist. +func (s *ArtistAlbumService) GetAlbumDetail(ctx context.Context, id string) (*models.AlbumDetail, error) { + album, err := s.repo.GetAlbumByID(ctx, id) + if err != nil { + return nil, err + } + if album == nil { + return nil, nil + } + + tracks, err := s.repo.GetAlbumTracks(ctx, album.ID) + if err != nil { + return nil, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + + return &models.AlbumDetail{ + Album: album, + Tracks: browseItems, + }, nil +} + +// GetAlbumTracks returns only the tracks of an album. +func (s *ArtistAlbumService) GetAlbumTracks(ctx context.Context, id string) ([]*models.BrowseItem, error) { + tracks, err := s.repo.GetAlbumTracks(ctx, id) + if err != nil { + return nil, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + return browseItems, nil +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 0000000..09828b8 --- /dev/null +++ b/internal/services/auth_service.go @@ -0,0 +1,398 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// AuthService handles Telegram WebApp/Widget authentication and token issuance. +type AuthService struct { + cfg *config.Config + userRepo repository.UserRepository +} + +// NewAuthService creates a new AuthService instance. +func NewAuthService(cfg *config.Config, userRepo repository.UserRepository) *AuthService { + return &AuthService{ + cfg: cfg, + userRepo: userRepo, + } +} + +func (s *AuthService) getSecretCandidates() [][]byte { + candidates := make([][]byte, 0, 3) + if s.cfg.SecretKey != "" { + candidates = append(candidates, []byte(s.cfg.SecretKey)) + } + if s.cfg.BotToken != "" { + candidates = append(candidates, []byte(s.cfg.BotToken)) + } + candidates = append(candidates, []byte("SHA234567JDNKDNSNNFNDKSMSERTYUWERTY")) + return candidates +} + +func (s *AuthService) primarySecret() []byte { + if s.cfg.SecretKey != "" { + return []byte(s.cfg.SecretKey) + } + if s.cfg.BotToken != "" { + return []byte(s.cfg.BotToken) + } + return []byte("SHA234567JDNKDNSNNFNDKSMSERTYUWERTY") +} + +// ValidateTelegramInitData verifies the HMAC-SHA256 signature of Telegram WebApp initData. +func (s *AuthService) ValidateTelegramInitData(initData string) (*models.User, error) { + initData = strings.TrimSpace(initData) + if initData == "" { + return nil, errors.New("init_data is required") + } + + values, err := url.ParseQuery(initData) + if err != nil { + return nil, fmt.Errorf("invalid init_data query string: %w", err) + } + + receivedHash := values.Get("hash") + if receivedHash == "" { + return nil, errors.New("hash is missing from init_data") + } + values.Del("hash") + + // Sort keys alphabetically + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + + var checkPairs []string + for _, k := range keys { + checkPairs = append(checkPairs, fmt.Sprintf("%s=%s", k, values.Get(k))) + } + dataCheckString := strings.Join(checkPairs, "\n") + + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return nil, errors.New("BOT_TOKEN is not configured") + } + + // Telegram WebApp Secret Key = HMAC-SHA256("WebAppData", botToken) + h := hmac.New(sha256.New, []byte("WebAppData")) + h.Write([]byte(botToken)) + secretKey := h.Sum(nil) + + // Calculated Hash = HMAC-SHA256(secretKey, dataCheckString) + hData := hmac.New(sha256.New, secretKey) + hData.Write([]byte(dataCheckString)) + calculatedHash := hex.EncodeToString(hData.Sum(nil)) + + if subtle.ConstantTimeCompare([]byte(calculatedHash), []byte(receivedHash)) != 1 { + return nil, errors.New("invalid Telegram signature") + } + + userJSON := values.Get("user") + if userJSON == "" { + return nil, errors.New("user payload missing from init_data") + } + + var tgPayload struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + PhotoURL string `json:"photo_url"` + } + if err := json.Unmarshal([]byte(userJSON), &tgPayload); err != nil { + return nil, fmt.Errorf("failed to parse user json: %w", err) + } + if tgPayload.ID <= 0 { + return nil, errors.New("invalid user id in init_data") + } + + profileURL := tgPayload.PhotoURL + if profileURL == "" && tgPayload.Username != "" { + profileURL = fmt.Sprintf("https://t.me/i/userpic/320/%s.jpg", tgPayload.Username) + } + + return &models.User{ + ID: tgPayload.ID, + UserID: tgPayload.ID, + FirstName: tgPayload.FirstName, + Username: tgPayload.Username, + PhotoURL: tgPayload.PhotoURL, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: tgPayload.ID, + Username: tgPayload.Username, + }, + }, nil +} + +// ValidateTelegramWidget verifies callback data from Telegram Login Widget. +func (s *AuthService) ValidateTelegramWidget(req models.TelegramWidgetRequest) (*models.User, error) { + if req.ID <= 0 || req.Hash == "" { + return nil, errors.New("invalid widget payload") + } + + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return nil, errors.New("BOT_TOKEN is not configured") + } + + // Secret key for widget is SHA256(botToken) + secretKey := sha256.Sum256([]byte(botToken)) + + pairs := make(map[string]string) + pairs["id"] = strconv.FormatInt(req.ID, 10) + pairs["first_name"] = req.FirstName + if req.LastName != "" { + pairs["last_name"] = req.LastName + } + if req.Username != "" { + pairs["username"] = req.Username + } + if req.PhotoURL != "" { + pairs["photo_url"] = req.PhotoURL + } + pairs["auth_date"] = strconv.FormatInt(req.AuthDate, 10) + + keys := make([]string, 0, len(pairs)) + for k := range pairs { + keys = append(keys, k) + } + sort.Strings(keys) + + var checkLines []string + for _, k := range keys { + checkLines = append(checkLines, fmt.Sprintf("%s=%s", k, pairs[k])) + } + dataCheckString := strings.Join(checkLines, "\n") + + h := hmac.New(sha256.New, secretKey[:]) + h.Write([]byte(dataCheckString)) + calculatedHash := hex.EncodeToString(h.Sum(nil)) + + if subtle.ConstantTimeCompare([]byte(calculatedHash), []byte(req.Hash)) != 1 { + return nil, errors.New("invalid widget signature") + } + + profileURL := req.PhotoURL + if profileURL == "" && req.Username != "" { + profileURL = fmt.Sprintf("https://t.me/i/userpic/320/%s.jpg", req.Username) + } + + return &models.User{ + ID: req.ID, + UserID: req.ID, + FirstName: req.FirstName, + Username: req.Username, + PhotoURL: req.PhotoURL, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: req.ID, + Username: req.Username, + }, + }, nil +} + +// CreateToken creates a signed token interoperable with Python StreamXBot. +func (s *AuthService) CreateToken(u *models.User) (string, error) { + now := time.Now().Unix() + exp := now + (365 * 24 * 60 * 60) // 1 year + + payload := map[string]any{ + "uid": u.ID, + "user_id": u.ID, + "userid": u.ID, + "iat": now, + "exp": exp, + "first_name": u.FirstName, + "photo_url": u.PhotoURL, + "profile_url": u.ProfileURL, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + secret := s.primarySecret() + + h := hmac.New(sha256.New, secret) + h.Write([]byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return fmt.Sprintf("v1.%s.%s", payloadB64, sigB64), nil +} + +// VerifyToken verifies token signature and returns the user ID. +func (s *AuthService) VerifyToken(rawToken string) (int64, error) { + token := strings.TrimSpace(rawToken) + if token == "" { + return 0, errors.New("missing auth token") + } + + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + token = strings.TrimSpace(token[7:]) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return 0, errors.New("invalid auth token format") + } + + // 1. Format v1.{payload_b64}.{sig_b64} + if parts[0] == "v1" { + payloadB64 := parts[1] + sigB64 := parts[2] + + matched := false + candidates := s.getSecretCandidates() + for _, cand := range candidates { + h := hmac.New(sha256.New, cand) + h.Write([]byte(payloadB64)) + expectedSig := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(sigB64)) == 1 { + matched = true + break + } + } + + if !matched { + return 0, errors.New("invalid token signature") + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return 0, errors.New("malformed token payload") + } + + var payload struct { + UID int64 `json:"uid"` + UserID int64 `json:"user_id"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return 0, errors.New("invalid token payload json") + } + + uid := payload.UID + if uid == 0 { + uid = payload.UserID + } + if uid <= 0 { + return 0, errors.New("invalid user id in token") + } + + if payload.Exp > 0 && time.Now().Unix() > payload.Exp { + return 0, errors.New("auth token expired") + } + + return uid, nil + } + + // 2. Standard JWT fallback: header.payload.signature + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // Try with std padding if needed + if pb, err2 := base64.URLEncoding.DecodeString(parts[1]); err2 == nil { + payloadBytes = pb + } else { + return 0, errors.New("malformed jwt payload") + } + } + + var jwtPayload struct { + UID int64 `json:"uid"` + UserID int64 `json:"user_id"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payloadBytes, &jwtPayload); err != nil { + return 0, errors.New("invalid jwt payload json") + } + + uid := jwtPayload.UID + if uid == 0 { + uid = jwtPayload.UserID + } + if uid <= 0 { + return 0, errors.New("invalid user id in jwt") + } + + if jwtPayload.Exp > 0 && time.Now().Unix() > jwtPayload.Exp { + return 0, errors.New("jwt token expired") + } + + return uid, nil +} + +// AuthenticateTelegram validates Telegram initData, saves user, and creates token. +func (s *AuthService) AuthenticateTelegram(ctx context.Context, initData string) (*models.AuthResponse, error) { + u, err := s.ValidateTelegramInitData(initData) + if err != nil { + return nil, err + } + + savedUser, err := s.userRepo.UpsertUser(ctx, u) + if err != nil { + return nil, err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + User: savedUser, + }, nil +} + +// AuthenticateWidget validates Telegram Widget login, saves user, and creates token. +func (s *AuthService) AuthenticateWidget(ctx context.Context, req models.TelegramWidgetRequest) (*models.AuthResponse, error) { + u, err := s.ValidateTelegramWidget(req) + if err != nil { + return nil, err + } + + savedUser, err := s.userRepo.UpsertUser(ctx, u) + if err != nil { + return nil, err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + User: savedUser, + }, nil +} + +// GetUserByID returns user information by ID. +func (s *AuthService) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + return s.userRepo.GetUserByID(ctx, id) +} diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go new file mode 100644 index 0000000..39956ec --- /dev/null +++ b/internal/services/auth_service_test.go @@ -0,0 +1,112 @@ +package services_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "testing" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +type mockUserRepo struct { + users map[int64]*models.User +} + +func (m *mockUserRepo) UpsertUser(ctx context.Context, user *models.User) (*models.User, error) { + m.users[user.ID] = user + return user, nil +} + +func (m *mockUserRepo) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + u, ok := m.users[id] + if !ok { + return nil, nil + } + return u, nil +} + +func TestTelegramInitDataAuth(t *testing.T) { + botToken := "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + cfg := &config.Config{ + BotToken: botToken, + SecretKey: "supersecretkey123", + } + + repo := &mockUserRepo{users: make(map[int64]*models.User)} + authSvc := services.NewAuthService(cfg, repo) + + // Construct valid Telegram initData + userJSON := `{"id":987654321,"first_name":"Test","last_name":"User","username":"testuser","photo_url":"https://example.com/p.jpg"}` + authDate := "1726000000" + + // Prepare data_check_string + dataCheckString := fmt.Sprintf("auth_date=%s\nuser=%s", authDate, userJSON) + + // HMAC-SHA256("WebAppData", botToken) + h := hmac.New(sha256.New, []byte("WebAppData")) + h.Write([]byte(botToken)) + secretKey := h.Sum(nil) + + // HMAC-SHA256(secretKey, dataCheckString) + hData := hmac.New(sha256.New, secretKey) + hData.Write([]byte(dataCheckString)) + calculatedHash := hex.EncodeToString(hData.Sum(nil)) + + initData := fmt.Sprintf("auth_date=%s&user=%s&hash=%s", + url.QueryEscape(authDate), + url.QueryEscape(userJSON), + calculatedHash, + ) + + ctx := context.Background() + authResp, err := authSvc.AuthenticateTelegram(ctx, initData) + if err != nil { + t.Fatalf("AuthenticateTelegram failed: %v", err) + } + + if !authResp.OK { + t.Fatalf("expected OK=true, got false") + } + if authResp.User.ID != 987654321 { + t.Fatalf("expected user id 987654321, got %d", authResp.User.ID) + } + if authResp.Token == "" { + t.Fatalf("expected non-empty token") + } + + // Verify the token + userID, err := authSvc.VerifyToken(authResp.Token) + if err != nil { + t.Fatalf("VerifyToken failed: %v", err) + } + if userID != 987654321 { + t.Fatalf("expected verified user ID 987654321, got %d", userID) + } +} + +func TestVerifyTokenTampered(t *testing.T) { + cfg := &config.Config{ + SecretKey: "supersecretkey123", + } + repo := &mockUserRepo{users: make(map[int64]*models.User)} + authSvc := services.NewAuthService(cfg, repo) + + // Create valid token + u := &models.User{ID: 112233, FirstName: "Alice"} + tok, err := authSvc.CreateToken(u) + if err != nil { + t.Fatalf("CreateToken failed: %v", err) + } + + // Tampered token + tampered := tok + "tampered" + if _, err := authSvc.VerifyToken(tampered); err == nil { + t.Fatalf("expected error for tampered token, got nil") + } +} diff --git a/internal/services/favourite_playlist_service.go b/internal/services/favourite_playlist_service.go new file mode 100644 index 0000000..9a2a7e5 --- /dev/null +++ b/internal/services/favourite_playlist_service.go @@ -0,0 +1,294 @@ +package services + +import ( + "context" + "errors" + "math" + "strings" + "time" + + "github.com/google/uuid" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// FavouritePlaylistService orchestrates user favorites and playlist logic. +type FavouritePlaylistService struct { + favRepo repository.FavouritePlaylistRepository + trackRepo repository.TrackRepository +} + +// NewFavouritePlaylistService creates a new FavouritePlaylistService. +func NewFavouritePlaylistService(favRepo repository.FavouritePlaylistRepository, trackRepo repository.TrackRepository) *FavouritePlaylistService { + return &FavouritePlaylistService{ + favRepo: favRepo, + trackRepo: trackRepo, + } +} + +// AddFavourite likes a track for a user. +func (s *FavouritePlaylistService) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return false, errors.New("track_id is required") + } + track, err := s.trackRepo.GetByID(ctx, trackID) + if err != nil { + return false, err + } + if track == nil { + return false, errors.New("track not found") + } + return s.favRepo.AddFavourite(ctx, userID, trackID) +} + +// RemoveFavourite unlikes a track. +func (s *FavouritePlaylistService) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return false, errors.New("track_id is required") + } + return s.favRepo.RemoveFavourite(ctx, userID, trackID) +} + +// GetFavouriteIDs returns array of favorite track IDs. +func (s *FavouritePlaylistService) GetFavouriteIDs(ctx context.Context, userID int64, page, limit int) (*models.FavouriteIDsResponse, error) { + ids, _, _, err := s.favRepo.GetFavouriteTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, err + } + if ids == nil { + ids = []string{} + } + return &models.FavouriteIDsResponse{ + OK: true, + IDs: ids, + }, nil +} + +// GetFavourites returns paginated favorites converted to BrowseItems. +func (s *FavouritePlaylistService) GetFavourites(ctx context.Context, userID int64, page, limit int) (*models.BrowseResponse, error) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + + ids, total, _, err := s.favRepo.GetFavouriteTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + totalPages := 0 + if total > 0 { + totalPages = int(math.Ceil(float64(total) / float64(limit))) + } + + return &models.BrowseResponse{ + Items: items, + Total: total, + Page: page, + PerPage: limit, + TotalPages: totalPages, + }, nil +} + +// AddFavouriteArtist follows an artist. +func (s *FavouritePlaylistService) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + artistID = strings.TrimSpace(artistID) + if artistID == "" { + return false, errors.New("artist_id is required") + } + return s.favRepo.AddFavouriteArtist(ctx, userID, artistID) +} + +// RemoveFavouriteArtist unfollows an artist. +func (s *FavouritePlaylistService) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + artistID = strings.TrimSpace(artistID) + if artistID == "" { + return false, errors.New("artist_id is required") + } + return s.favRepo.RemoveFavouriteArtist(ctx, userID, artistID) +} + +// GetFavouriteArtistIDs returns list of followed artist IDs. +func (s *FavouritePlaylistService) GetFavouriteArtistIDs(ctx context.Context, userID int64) (*models.FavouriteArtistsResponse, error) { + ids, err := s.favRepo.GetFavouriteArtistIDs(ctx, userID) + if err != nil { + return nil, err + } + if ids == nil { + ids = []string{} + } + return &models.FavouriteArtistsResponse{ + OK: true, + IDs: ids, + }, nil +} + +// CreatePlaylist creates a new custom playlist. +func (s *FavouritePlaylistService) CreatePlaylist(ctx context.Context, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) { + title = strings.TrimSpace(title) + if title == "" { + return nil, errors.New("playlist title is required") + } + + p := &models.UserPlaylist{ + ID: uuid.New().String(), + UserID: userID, + Title: title, + Description: strings.TrimSpace(description), + CoverURL: strings.TrimSpace(coverURL), + TrackCount: 0, + CreatedAt: float64(time.Now().Unix()), + UpdatedAt: float64(time.Now().Unix()), + } + + if err := s.favRepo.CreatePlaylist(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +// GetUserPlaylists lists all playlists owned by a user. +func (s *FavouritePlaylistService) GetUserPlaylists(ctx context.Context, userID int64) (*models.PlaylistsResponse, error) { + playlists, err := s.favRepo.GetUserPlaylists(ctx, userID) + if err != nil { + return nil, err + } + if playlists == nil { + playlists = []*models.UserPlaylist{} + } + return &models.PlaylistsResponse{ + OK: true, + Total: len(playlists), + Items: playlists, + }, nil +} + +// GetPlaylist retrieves a playlist along with its items. +func (s *FavouritePlaylistService) GetPlaylist(ctx context.Context, playlistID string) (*models.PlaylistDetail, error) { + playlist, err := s.favRepo.GetPlaylistByID(ctx, playlistID) + if err != nil { + return nil, err + } + if playlist == nil { + return nil, nil + } + + trackIDs, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + return &models.PlaylistDetail{ + Playlist: playlist, + Tracks: items, + }, nil +} + +// UpdatePlaylist updates metadata of a user playlist. +func (s *FavouritePlaylistService) UpdatePlaylist(ctx context.Context, playlistID string, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) { + return s.favRepo.UpdatePlaylist(ctx, playlistID, userID, title, description, coverURL) +} + +// DeletePlaylist removes a playlist and its track relations. +func (s *FavouritePlaylistService) DeletePlaylist(ctx context.Context, playlistID string, userID int64) (bool, error) { + return s.favRepo.DeletePlaylist(ctx, playlistID, userID) +} + +// AddTracksToPlaylist adds multiple tracks to an existing playlist. +func (s *FavouritePlaylistService) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error { + return s.favRepo.AddTracksToPlaylist(ctx, playlistID, trackIDs) +} + +// RemoveTrackFromPlaylist removes a single track from a playlist. +func (s *FavouritePlaylistService) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { + return s.favRepo.RemoveTrackFromPlaylist(ctx, playlistID, trackID) +} + +// GetPlaylistTracks returns the ordered tracks inside a playlist. +func (s *FavouritePlaylistService) GetPlaylistTracks(ctx context.Context, playlistID string) ([]*models.BrowseItem, error) { + trackIDs, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + return items, nil +} + +// ReorderPlaylistTracks updates the order of tracks in a playlist. +func (s *FavouritePlaylistService) ReorderPlaylistTracks(ctx context.Context, playlistID string, trackIDs []string) error { + return s.favRepo.ReorderPlaylistTracks(ctx, playlistID, trackIDs) +} + +// RecordHistory stores an entry in listening history. +func (s *FavouritePlaylistService) RecordHistory(ctx context.Context, userID int64, trackID string, playedAt float64) error { + return s.favRepo.RecordHistory(ctx, userID, trackID, playedAt) +} + +// GetUserHistory returns unique recently played tracks for a user. +func (s *FavouritePlaylistService) GetUserHistory(ctx context.Context, userID int64, limit int) (*models.BrowseResponse, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + + trackIDs, err := s.favRepo.GetUserHistoryTrackIDs(ctx, userID, limit) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + return &models.BrowseResponse{ + Items: items, + Total: int64(len(items)), + Page: 1, + PerPage: limit, + TotalPages: 1, + }, nil +} + +// RecordListeningEvents records telemetry playback events. +func (s *FavouritePlaylistService) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + return s.favRepo.RecordListeningEvents(ctx, userID, events) +} diff --git a/scripts/verify_endpoints.go b/scripts/verify_endpoints.go new file mode 100644 index 0000000..7266448 --- /dev/null +++ b/scripts/verify_endpoints.go @@ -0,0 +1,165 @@ +package main + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +func main() { + botToken := "8879601665:AAHWVfu6ckG3XPgePUe_RCwzpeT8SpmOZhI" + baseURL := "http://localhost:8000" + + fmt.Println("=== 1. Testing Health ===") + resp, err := http.Get(baseURL + "/health") + if err != nil { + panic(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("/health: %s\n\n", body) + + fmt.Println("=== 2. Testing Telegram WebApp Auth ===") + authDate := strconv.FormatInt(time.Now().Unix(), 10) + userJSON := `{"id":999888777,"first_name":"GoTester","last_name":"Stream","username":"gotester"}` + dataCheckString := fmt.Sprintf("auth_date=%s\nuser=%s", authDate, userJSON) + + hSecret := hmac.New(sha256.New, []byte("WebAppData")) + hSecret.Write([]byte(botToken)) + secretKey := hSecret.Sum(nil) + + hHash := hmac.New(sha256.New, secretKey) + hHash.Write([]byte(dataCheckString)) + calcHash := hex.EncodeToString(hHash.Sum(nil)) + + initData := fmt.Sprintf("auth_date=%s&user=%s&hash=%s", + url.QueryEscape(authDate), + url.QueryEscape(userJSON), + calcHash, + ) + + authPayload, _ := json.Marshal(map[string]string{"init_data": initData}) + authResp, err := http.Post(baseURL+"/auth/telegram", "application/json", bytes.NewReader(authPayload)) + if err != nil { + panic(err) + } + var authResult struct { + OK bool `json:"ok"` + Token string `json:"token"` + User struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + } `json:"user"` + } + json.NewDecoder(authResp.Body).Decode(&authResult) + authResp.Body.Close() + fmt.Printf("Auth Success: %v, Token: %s, User: %s (ID: %d)\n\n", authResult.OK, authResult.Token[:25]+"...", authResult.User.FirstName, authResult.User.ID) + + token := authResult.Token + + fmt.Println("=== 3. Testing GET /auth/me ===") + req, _ := http.NewRequest("GET", baseURL+"/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + meResp, err := http.DefaultClient.Do(req) + if err != nil { + panic(err) + } + meBody, _ := io.ReadAll(meResp.Body) + meResp.Body.Close() + fmt.Printf("/auth/me: %s\n\n", meBody) + + fmt.Println("=== 4. Testing Favourites (Add, List, Check, Remove) ===") + favPayload, _ := json.Marshal(map[string]string{"track_id": "AgADjx8AAtO9YFU"}) + favReq, _ := http.NewRequest("POST", baseURL+"/favourites", bytes.NewReader(favPayload)) + favReq.Header.Set("Authorization", "Bearer "+token) + favReq.Header.Set("Content-Type", "application/json") + favResp, err := http.DefaultClient.Do(favReq) + if err != nil { + panic(err) + } + favBody, _ := io.ReadAll(favResp.Body) + favResp.Body.Close() + fmt.Printf("POST /favourites: %s\n", favBody) + + listFavReq, _ := http.NewRequest("GET", baseURL+"/favourites", nil) + listFavReq.Header.Set("Authorization", "Bearer "+token) + listFavResp, _ := http.DefaultClient.Do(listFavReq) + listFavBody, _ := io.ReadAll(listFavResp.Body) + listFavResp.Body.Close() + fmt.Printf("GET /favourites: %s\n", listFavBody) + + idsFavReq, _ := http.NewRequest("GET", baseURL+"/favourites/ids", nil) + idsFavReq.Header.Set("Authorization", "Bearer "+token) + idsFavResp, _ := http.DefaultClient.Do(idsFavReq) + idsFavBody, _ := io.ReadAll(idsFavResp.Body) + idsFavResp.Body.Close() + fmt.Printf("GET /favourites/ids: %s\n", idsFavBody) + + delFavReq, _ := http.NewRequest("DELETE", baseURL+"/favourites/AgADjx8AAtO9YFU", nil) + delFavReq.Header.Set("Authorization", "Bearer "+token) + delFavResp, _ := http.DefaultClient.Do(delFavReq) + delFavBody, _ := io.ReadAll(delFavResp.Body) + delFavResp.Body.Close() + fmt.Printf("DELETE /favourites/AgADjx8AAtO9YFU: %s\n\n", delFavBody) + + fmt.Println("=== 5. Testing Playlists (Create, Add Tracks, Detail, Delete) ===") + plPayload, _ := json.Marshal(map[string]string{ + "title": "Live Verification Playlist", + "description": "Created by Go test suite", + }) + plReq, _ := http.NewRequest("POST", baseURL+"/playlists", bytes.NewReader(plPayload)) + plReq.Header.Set("Authorization", "Bearer "+token) + plReq.Header.Set("Content-Type", "application/json") + plResp, err := http.DefaultClient.Do(plReq) + if err != nil { + panic(err) + } + var createdPL struct { + ID string `json:"_id"` + Title string `json:"title"` + } + json.NewDecoder(plResp.Body).Decode(&createdPL) + plResp.Body.Close() + fmt.Printf("Created Playlist: ID=%s, Title=%s\n", createdPL.ID, createdPL.Title) + + // Add track to playlist + addTrkPayload, _ := json.Marshal(map[string]string{"track_id": "AgADjx8AAtO9YFU"}) + addTrkReq, _ := http.NewRequest("POST", baseURL+"/playlists/"+createdPL.ID+"/tracks", bytes.NewReader(addTrkPayload)) + addTrkReq.Header.Set("Authorization", "Bearer "+token) + addTrkReq.Header.Set("Content-Type", "application/json") + addTrkResp, _ := http.DefaultClient.Do(addTrkReq) + addTrkBody, _ := io.ReadAll(addTrkResp.Body) + addTrkResp.Body.Close() + fmt.Printf("POST /playlists/{id}/tracks: %s\n", addTrkBody) + + // Get playlist detail + getPLReq, _ := http.NewRequest("GET", baseURL+"/playlists/"+createdPL.ID, nil) + getPLResp, _ := http.DefaultClient.Do(getPLReq) + getPLBody, _ := io.ReadAll(getPLResp.Body) + getPLResp.Body.Close() + fmt.Printf("GET /playlists/{id}: %s\n", getPLBody) + + // Delete playlist + delPLReq, _ := http.NewRequest("DELETE", baseURL+"/playlists/"+createdPL.ID, nil) + delPLReq.Header.Set("Authorization", "Bearer "+token) + delPLResp, _ := http.DefaultClient.Do(delPLReq) + delPLBody, _ := io.ReadAll(delPLResp.Body) + delPLResp.Body.Close() + fmt.Printf("DELETE /playlists/{id}: %s\n\n", delPLBody) + + fmt.Println("=== 6. Testing Cover HEAD and GET ===") + headCoverResp, _ := http.Head(baseURL + "/cover/AgADjx8AAtO9YFU") + fmt.Printf("HEAD /cover/AgADjx8AAtO9YFU: Status %d, Location: %s\n", headCoverResp.StatusCode, headCoverResp.Header.Get("Location")) + + fmt.Println("\n>>> ALL TESTS COMPLETED SUCCESSFULLY! <<<") + os.Exit(0) +} From 56d47210bdac26b1ec4ee4e2c9a376a0b2e62719 Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 18 Sep 2026 21:31:44 +0530 Subject: [PATCH 07/39] partial --- cmd/server/main.go | 23 +- internal/config/config.go | 56 +- internal/models/track.go | 65 ++- internal/server/router.go | 11 +- internal/services/access_filter.go | 118 ++++ internal/services/cover_search.go | 224 ++++++++ internal/services/dedup_service.go | 98 ++++ internal/services/enrichment_service.go | 348 ++++++++++++ internal/services/internal_services_test.go | 90 +++ .../services/lyrics_enrichment_service.go | 105 ++++ internal/services/mediainfo.go | 534 ++++++++++++++++++ internal/services/mediainfo_test.go | 129 +++++ internal/services/stream_service.go | 8 +- internal/telegram/client.go | 356 +++++++++--- internal/telegram/client_test.go | 62 ++ internal/telegram/fileid.go | 84 +++ internal/telegram/fileid_test.go | 20 + internal/telegram/listener.go | 364 ++++++++++++ 18 files changed, 2576 insertions(+), 119 deletions(-) create mode 100644 internal/services/access_filter.go create mode 100644 internal/services/cover_search.go create mode 100644 internal/services/dedup_service.go create mode 100644 internal/services/enrichment_service.go create mode 100644 internal/services/internal_services_test.go create mode 100644 internal/services/lyrics_enrichment_service.go create mode 100644 internal/services/mediainfo.go create mode 100644 internal/services/mediainfo_test.go create mode 100644 internal/telegram/client_test.go create mode 100644 internal/telegram/listener.go diff --git a/cmd/server/main.go b/cmd/server/main.go index b118e55..a51851d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "streamgo/internal/database" "streamgo/internal/logger" "streamgo/internal/server" + "streamgo/internal/services" "streamgo/internal/telegram" ) @@ -50,7 +51,18 @@ func main() { } } - // 3. Initialize Telegram Service (optional / background) + // 3. Initialize Internal Auxiliary Services + coverSearch := services.NewCoverSearchService() + lyricsSvc := services.NewLyricsEnrichmentService() + dedupSvc := services.NewDedupService(dbClient) + accessFilter := services.NewAccessFilter(cfg, dbClient) + enrichSvc := services.NewEnrichmentService(dbClient, coverSearch, lyricsSvc) + + if dbClient != nil { + enrichSvc.Start(ctx, 2) + } + + // 4. Initialize Telegram Service (Multi-Client MTProto Pool) var tgService *telegram.Service if cfg.ApiID > 0 && cfg.ApiHash != "" { svc, err := telegram.New(cfg) @@ -58,7 +70,13 @@ func main() { log.Warnf("Failed to initialize Telegram service: %v", err) } else { tgService = svc - log.Info("Starting Telegram MTProto client in background...") + enrichSvc.SetDownloader(tgService) + + // Attach Ingestion Listener for live channel/group audio uploads + listener := telegram.NewIngestionListener(cfg, dbClient, accessFilter, dedupSvc, enrichSvc.TriggerEnrich) + listener.SetupDispatcher(&tgService.Dispatcher) + + log.Info("Starting Telegram MTProto multi-client pool in background...") go func() { if err := tgService.Start(ctx); err != nil { log.Errorf("Telegram client start failed: %v", err) @@ -89,6 +107,7 @@ func main() { }() // 6. Graceful Shutdown on OS Signal (SIGINT, SIGTERM) + signal.Ignore(syscall.SIGHUP) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) sig := <-quit diff --git a/internal/config/config.go b/internal/config/config.go index 5090b39..a361b1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,11 +24,13 @@ type Config struct { BotToken string SecretKey string SessionString string - ChannelID int64 - DumpChannelID int64 + ChannelID int64 + DumpChannelID int64 + MultiClients bool + MultiClientTokens []string + FilterMode int } - // Load reads configuration from .env file (if present) and environment variables. func Load() *Config { // Try loading .env; ignore error if not found (e.g., in container/production) @@ -45,23 +47,45 @@ func Load() *Config { // Sanitize any accidental leading equals sign (e.g. MONGO_URI==...) mongoURI = strings.TrimPrefix(mongoURI, "=") + multiClients := getEnvBool("MULTI_CLIENTS", false) + var multiTokens []string + for _, key := range []string{"MULTI_CLIENTS_1", "MULTI_CLIENTS_2", "MULTI_CLIENTS_3", "MULTI_CLIENTS_4"} { + if val := strings.TrimSpace(os.Getenv(key)); val != "" { + multiTokens = append(multiTokens, val) + } + } + if extra := strings.TrimSpace(os.Getenv("MULTI_CLIENT_TOKENS")); extra != "" { + for _, part := range strings.Split(extra, ",") { + if p := strings.TrimSpace(part); p != "" { + multiTokens = append(multiTokens, p) + } + } + } + if len(multiTokens) > 0 { + multiClients = true + } + return &Config{ - Port: getEnv("PORT", "8000"), - Debug: debug, - APILogs: getEnvBool("API_LOGS", getEnvBool("HTTP_LOGS", false)), - CorsOrigin: getEnv("CORS_ORIGIN", "*"), - MongoURI: mongoURI, - DatabaseName: getEnv("DATABASE_NAME", "Stream"), - ApiID: getEnvInt("API_ID", 0), - ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), - BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), - SecretKey: strings.TrimSpace(os.Getenv("SECRET_KEY")), - SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), - ChannelID: getEnvInt64("CHANNEL_ID", 0), - DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), + Port: getEnv("PORT", "8000"), + Debug: debug, + APILogs: getEnvBool("API_LOGS", getEnvBool("HTTP_LOGS", false)), + CorsOrigin: getEnv("CORS_ORIGIN", "*"), + MongoURI: mongoURI, + DatabaseName: getEnv("DATABASE_NAME", "Stream"), + ApiID: getEnvInt("API_ID", 0), + ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), + BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), + SecretKey: strings.TrimSpace(os.Getenv("SECRET_KEY")), + SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), + ChannelID: getEnvInt64("CHANNEL_ID", 0), + DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), + MultiClients: multiClients, + MultiClientTokens: multiTokens, + FilterMode: getEnvInt("FILTER_MODE", 0), } } + func getEnv(key, defaultVal string) string { if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { return strings.TrimSpace(val) diff --git a/internal/models/track.go b/internal/models/track.go index 7bab910..fa3d3f7 100644 --- a/internal/models/track.go +++ b/internal/models/track.go @@ -1,22 +1,29 @@ package models -// AudioMeta represents metadata extracted from the audio stream or tags. +// AudioMeta represents metadata extracted from the audio stream or tags via MediaInfo. type AudioMeta struct { Title string `bson:"title,omitempty" json:"title"` - Artist string `bson:"artist,omitempty" json:"artist"` - Performer string `bson:"performer,omitempty" json:"performer,omitempty"` Album string `bson:"album,omitempty" json:"album,omitempty"` - AlbumID string `bson:"album_id,omitempty" json:"album_id,omitempty"` - Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` - DurationSec float64 `bson:"duration_sec,omitempty" json:"duration_sec"` + Artist string `bson:"artist,omitempty" json:"artist"` + Composer string `bson:"composer,omitempty" json:"composer,omitempty"` + Label string `bson:"label,omitempty" json:"label,omitempty"` + Genre string `bson:"genre,omitempty" json:"genre,omitempty"` + Year *int32 `bson:"year,omitempty" json:"year,omitempty"` + DurationSec int32 `bson:"duration_sec" json:"duration_sec"` Type string `bson:"type,omitempty" json:"type,omitempty"` - SamplingRateHz int `bson:"sampling_rate_hz,omitempty" json:"sampling_rate_hz,omitempty"` - Bitrate int `bson:"bitrate,omitempty" json:"bitrate,omitempty"` - MimeType string `bson:"mime_type,omitempty" json:"mime_type,omitempty"` - FileSize int64 `bson:"file_size,omitempty" json:"file_size,omitempty"` - CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` - Lyrics string `bson:"lyrics,omitempty" json:"lyrics,omitempty"` - Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` + BitDepth *int32 `bson:"bit_depth,omitempty" json:"bit_depth,omitempty"` + BitrateKbps *int32 `bson:"bitrate_kbps,omitempty" json:"bitrate_kbps,omitempty"` + SamplingRateHz *int32 `bson:"sampling_rate_hz,omitempty" json:"sampling_rate_hz,omitempty"` + Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` + AlbumID string `bson:"album_id,omitempty" json:"album_id,omitempty"` + + // Legacy / Compatibility fields that should NOT be serialized to MongoDB audio subdocument + Performer string `bson:"performer,omitempty" json:"performer,omitempty"` + CoverURL string `bson:"-" json:"cover_url,omitempty"` + Lyrics string `bson:"-" json:"lyrics,omitempty"` + Titles map[string]any `bson:"-" json:"titles,omitempty"` + MimeType string `bson:"-" json:"mime_type,omitempty"` + FileSize int64 `bson:"-" json:"file_size,omitempty"` } // TelegramMeta stores Telegram file references and chat mapping. @@ -33,6 +40,9 @@ type TelegramMeta struct { type SpotifyMeta struct { URL string `bson:"url,omitempty" json:"url,omitempty"` CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + BigCoverURL string `bson:"big_cover_url,omitempty" json:"big_cover_url,omitempty"` + CoverSource string `bson:"cover_source,omitempty" json:"cover_source,omitempty"` + ArtistAvatar string `bson:"artist_avatar,omitempty" json:"artist_avatar,omitempty"` TrackSpotifyID string `bson:"track_spotify_id,omitempty" json:"track_spotify_id,omitempty"` } @@ -43,17 +53,23 @@ type Track struct { Telegram TelegramMeta `bson:"telegram" json:"telegram"` Spotify SpotifyMeta `bson:"spotify,omitempty" json:"spotify,omitempty"` Lyrics string `bson:"lyrics,omitempty" json:"lyrics,omitempty"` + LyricsCache map[string]any `bson:"lyrics_cache,omitempty" json:"lyrics_cache,omitempty"` Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` + Fingerprint string `bson:"fingerprint,omitempty" json:"fingerprint,omitempty"` + ContentHash string `bson:"content_hash,omitempty" json:"content_hash,omitempty"` SourceChatID int64 `bson:"source_chat_id,omitempty" json:"source_chat_id,omitempty"` - SourceMessageID int64 `bson:"source_message_id,omitempty" json:"source_message_id,omitempty"` + SourceMessageID int32 `bson:"source_message_id,omitempty" json:"source_message_id,omitempty"` CacheChatID int64 `bson:"cache_chat_id,omitempty" json:"cache_chat_id,omitempty"` - CacheMessageID int64 `bson:"cache_message_id,omitempty" json:"cache_message_id,omitempty"` - TopicID int64 `bson:"topic_id,omitempty" json:"topic_id,omitempty"` + CacheMessageID int32 `bson:"cache_message_id,omitempty" json:"cache_message_id,omitempty"` + TopicID int32 `bson:"topic_id,omitempty" json:"topic_id"` TopicName string `bson:"topic_name,omitempty" json:"topic_name,omitempty"` - PlayCount int64 `bson:"play_count,omitempty" json:"play_count"` + PlayCount int32 `bson:"play_count,omitempty" json:"play_count"` LikesCount int64 `bson:"likes_count,omitempty" json:"likes_count"` CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` + EnrichedAt float64 `bson:"enriched_at,omitempty" json:"enriched_at"` + Enriched bool `bson:"enriched" json:"enriched"` + Indexed bool `bson:"indexed" json:"indexed"` Deleted bool `bson:"deleted,omitempty" json:"deleted"` Liked bool `bson:"-" json:"liked"` } @@ -63,6 +79,9 @@ func (t *Track) EffectiveCoverURL() string { if t.Spotify.CoverURL != "" { return t.Spotify.CoverURL } + if t.Spotify.BigCoverURL != "" { + return t.Spotify.BigCoverURL + } return t.Audio.CoverURL } @@ -89,16 +108,18 @@ func (t *Track) EffectiveTitles() map[string]any { type BrowseItem struct { ID string `json:"_id"` SourceChatID int64 `json:"source_chat_id,omitempty"` - SourceMessageID int64 `json:"source_message_id,omitempty"` - TopicID int64 `json:"topic_id,omitempty"` + SourceMessageID int32 `json:"source_message_id,omitempty"` + TopicID int32 `json:"topic_id,omitempty"` TopicName string `json:"topic_name,omitempty"` Title string `json:"title"` Artist string `json:"artist"` Album string `json:"album,omitempty"` AlbumID string `json:"album_id,omitempty"` - DurationSec float64 `json:"duration_sec"` + DurationSec int32 `json:"duration_sec"` Type string `json:"type,omitempty"` - SamplingRateHz int `json:"sampling_rate_hz,omitempty"` + SamplingRateHz *int32 `json:"sampling_rate_hz,omitempty"` + BitDepth *int32 `json:"bit_depth,omitempty"` + BitrateKbps *int32 `json:"bitrate_kbps,omitempty"` SpotifyURL string `json:"spotify_url,omitempty"` CoverURL string `json:"cover_url,omitempty"` Titles map[string]any `json:"titles,omitempty"` @@ -122,6 +143,8 @@ func (t *Track) ToBrowseItem() *BrowseItem { DurationSec: t.Audio.DurationSec, Type: t.Audio.Type, SamplingRateHz: t.Audio.SamplingRateHz, + BitDepth: t.Audio.BitDepth, + BitrateKbps: t.Audio.BitrateKbps, SpotifyURL: t.Spotify.URL, CoverURL: t.EffectiveCoverURL(), Titles: t.EffectiveTitles(), diff --git a/internal/server/router.go b/internal/server/router.go index 302b4ca..d83ff85 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -140,16 +140,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { tgStatus := "disabled" if s.TG != nil { - if s.TG.IsReady() { - self := s.TG.Self() - if self != nil { - tgStatus = "connected (" + self.FirstName + ")" - } else { - tgStatus = "connected" - } - } else { - tgStatus = "connecting" - } + tgStatus = s.TG.StatusString() } respondJSON(w, http.StatusOK, map[string]interface{}{ diff --git a/internal/services/access_filter.go b/internal/services/access_filter.go new file mode 100644 index 0000000..bf2f273 --- /dev/null +++ b/internal/services/access_filter.go @@ -0,0 +1,118 @@ +package services + +import ( + "context" + "sync" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "streamgo/internal/config" + "streamgo/internal/database" +) + +const ( + FilterModeGroupOnly = 0 + FilterModeAnyone = 1 + FilterModeHybrid = 2 +) + +// AccessFilter checks chat access rights before indexing or streaming media. +type AccessFilter struct { + cfg *config.Config + col *mongo.Collection + bannedCol *mongo.Collection + + mu sync.RWMutex + allowedChats map[int64]bool + bannedUsers map[int64]bool + lastRefresh time.Time +} + +// NewAccessFilter creates an AccessFilter instance. +func NewAccessFilter(cfg *config.Config, db *database.Client) *AccessFilter { + var col, bannedCol *mongo.Collection + if db != nil { + col = db.Collection("source_chats") + bannedCol = db.Collection("banned_users") + } + + af := &AccessFilter{ + cfg: cfg, + col: col, + bannedCol: bannedCol, + allowedChats: make(map[int64]bool), + bannedUsers: make(map[int64]bool), + } + + if cfg.ChannelID != 0 { + af.allowedChats[cfg.ChannelID] = true + } + if cfg.DumpChannelID != 0 { + af.allowedChats[cfg.DumpChannelID] = true + } + + return af +} + +// RefreshCache loads allowed and banned lists from MongoDB. +func (f *AccessFilter) RefreshCache(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.col != nil { + cursor, err := f.col.Find(ctx, bson.M{}) + if err == nil { + defer cursor.Close(ctx) + newAllowed := make(map[int64]bool) + if f.cfg.ChannelID != 0 { + newAllowed[f.cfg.ChannelID] = true + } + if f.cfg.DumpChannelID != 0 { + newAllowed[f.cfg.DumpChannelID] = true + } + + for cursor.Next(ctx) { + var doc struct { + ChatID int64 `bson:"chat_id"` + ID int64 `bson:"_id"` + } + if err := cursor.Decode(&doc); err == nil { + cid := doc.ChatID + if cid == 0 { + cid = doc.ID + } + if cid != 0 { + newAllowed[cid] = true + } + } + } + f.allowedChats = newAllowed + } + } + + f.lastRefresh = time.Now() + return nil +} + +// IsChatAllowed determines if an incoming message or channel is authorized. +func (f *AccessFilter) IsChatAllowed(ctx context.Context, chatID int64) bool { + mode := f.cfg.FilterMode + + // Mode 1: Anyone is allowed + if mode == FilterModeAnyone { + return true + } + + f.mu.RLock() + if time.Since(f.lastRefresh) > 60*time.Second { + f.mu.RUnlock() + _ = f.RefreshCache(ctx) + f.mu.RLock() + } + allowed := f.allowedChats[chatID] + f.mu.RUnlock() + + return allowed +} diff --git a/internal/services/cover_search.go b/internal/services/cover_search.go new file mode 100644 index 0000000..a79dacc --- /dev/null +++ b/internal/services/cover_search.go @@ -0,0 +1,224 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "streamgo/internal/logger" +) + +var logCover = logger.New("cover_search") + +// CoverSearchService searches for high-resolution album artwork and artist avatars. +type CoverSearchService struct { + httpClient *http.Client +} + +// NewCoverSearchService creates a new CoverSearchService. +func NewCoverSearchService() *CoverSearchService { + return &CoverSearchService{ + httpClient: &http.Client{ + Timeout: 8 * time.Second, + }, + } +} + +// StripNoise removes common junk, watermarks, and descriptors from titles/artists for clean search. +func StripNoise(text string) string { + s := strings.TrimSpace(text) + if s == "" { + return "" + } + + // Remove disc/volume markers + reDisc := regexp.MustCompile(`(?i)[\(\[]\s*(?:disc|cd|tape|vol|volume)\s*\d+[\)\]]`) + s = reDisc.ReplaceAllString(s, " ") + + // Remove official video, lyric video, remaster, audio descriptors (with optional year) + reDescriptors := regexp.MustCompile(`(?i)[\(\[]\s*(?:official\s+(?:audio|video|music\s+video)|lyric(?:s)?\s+video|official|audio|video|mv|visualizer|remaster(?:ed)?(?:\s+\d+)?|hd|4k|explicit|clean|flac|320kbps|24bit|live(?:\s+at[^\]\)]+)?)\s*[\)\]]`) + s = reDescriptors.ReplaceAllString(s, " ") + + // Remove standalone noise words + reStandalone := regexp.MustCompile(`(?i)\b(?:official\s+(?:audio|video|music\s+video)|lyric(?:s)?\s+video|visualizer|remaster(?:ed)?(?:\s+\d+)?|explicit|clean)\b`) + s = reStandalone.ReplaceAllString(s, " ") + + // Remove unclosed brackets + if (strings.Contains(s, "(") && !strings.Contains(s, ")")) || (strings.Contains(s, "[") && !strings.Contains(s, "]")) { + s = strings.ReplaceAll(s, "(", " ") + s = strings.ReplaceAll(s, "[", " ") + } + + // Remove telegram channel prefixes or watermarks (@channel_name) + reChannel := regexp.MustCompile(`(?i)@\w+`) + s = reChannel.ReplaceAllString(s, " ") + + // Normalize spaces + reSpaces := regexp.MustCompile(`\s+`) + s = reSpaces.ReplaceAllString(s, " ") + + return strings.TrimSpace(s) +} + +// FindBestCover attempts to find the best album artwork using iTunes and Deezer APIs. +func (s *CoverSearchService) FindBestCover(ctx context.Context, title, artist, album string) (coverURL, thumbURL string, err error) { + cleanTitle := StripNoise(title) + cleanArtist := StripNoise(artist) + cleanAlbum := StripNoise(album) + + // 1. Try iTunes Search API + if cover, thumb, err := s.searchITunes(ctx, cleanTitle, cleanArtist); err == nil && cover != "" { + return cover, thumb, nil + } + + // 2. Try Deezer Search API + if cover, thumb, err := s.searchDeezer(ctx, cleanTitle, cleanArtist); err == nil && cover != "" { + return cover, thumb, nil + } + + // 3. Try with Album name if available + if cleanAlbum != "" { + if cover, thumb, err := s.searchITunes(ctx, cleanAlbum, cleanArtist); err == nil && cover != "" { + return cover, thumb, nil + } + } + + return "", "", fmt.Errorf("no cover found for %s - %s", artist, title) +} + +// searchITunes queries the Apple iTunes Search API for artwork. +func (s *CoverSearchService) searchITunes(ctx context.Context, title, artist string) (coverURL, thumbURL string, err error) { + query := fmt.Sprintf("%s %s", artist, title) + apiURL := fmt.Sprintf("https://itunes.apple.com/search?term=%s&entity=song&limit=3", url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("itunes api returned status %d", resp.StatusCode) + } + + var payload struct { + ResultCount int `json:"resultCount"` + Results []struct { + ArtworkUrl100 string `json:"artworkUrl100"` + ArtworkUrl60 string `json:"artworkUrl60"` + } `json:"results"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", "", err + } + + if payload.ResultCount > 0 && len(payload.Results) > 0 { + raw := payload.Results[0].ArtworkUrl100 + if raw != "" { + // Upgrade 100x100 to ultra-high-res 1000x1000bb.jpg + highRes := strings.Replace(raw, "100x100bb.jpg", "1000x1000bb.jpg", 1) + return highRes, payload.Results[0].ArtworkUrl60, nil + } + } + + return "", "", nil +} + +// searchDeezer queries Deezer API for album cover art. +func (s *CoverSearchService) searchDeezer(ctx context.Context, title, artist string) (coverURL, thumbURL string, err error) { + query := fmt.Sprintf(`artist:"%s" track:"%s"`, artist, title) + apiURL := fmt.Sprintf("https://api.deezer.com/search?q=%s&limit=3", url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("deezer api returned status %d", resp.StatusCode) + } + + var payload struct { + Total int `json:"total"` + Data []struct { + Album struct { + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + CoverMed string `json:"cover_medium"` + } `json:"album"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", "", err + } + + if payload.Total > 0 && len(payload.Data) > 0 { + alb := payload.Data[0].Album + cover := alb.CoverXL + if cover == "" { + cover = alb.CoverBig + } + if cover != "" { + return cover, alb.CoverMed, nil + } + } + + return "", "", nil +} + +// FindArtistAvatar fetches high-resolution artist profile picture from Deezer API. +func (s *CoverSearchService) FindArtistAvatar(ctx context.Context, artist string) (string, error) { + cleanArtist := StripNoise(artist) + apiURL := fmt.Sprintf("https://api.deezer.com/search/artist?q=%s&limit=1", url.QueryEscape(cleanArtist)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var payload struct { + Data []struct { + PictureXL string `json:"picture_xl"` + PictureBig string `json:"picture_big"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + + if len(payload.Data) > 0 { + pic := payload.Data[0].PictureXL + if pic == "" { + pic = payload.Data[0].PictureBig + } + return pic, nil + } + + return "", nil +} diff --git a/internal/services/dedup_service.go b/internal/services/dedup_service.go new file mode 100644 index 0000000..3d20689 --- /dev/null +++ b/internal/services/dedup_service.go @@ -0,0 +1,98 @@ +package services + +import ( + "context" + "errors" + "fmt" + "math" + "regexp" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// DedupService calculates audio fingerprints and detects duplicate track uploads. +type DedupService struct { + col *mongo.Collection +} + +// NewDedupService creates a new DedupService instance. +func NewDedupService(db *database.Client) *DedupService { + var col *mongo.Collection + if db != nil { + col = db.Collection("audioTracks") + } + return &DedupService{col: col} +} + +// NormalizeText cleans and strips non-alphanumeric characters for fingerprinting. +func NormalizeText(text string) string { + s := strings.ToLower(text) + reNonAlpha := regexp.MustCompile(`[^a-z0-9]+`) + s = reNonAlpha.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + +// MetadataFingerprint generates a fuzzy bucketed fingerprint: title|artist|album|duration. +func MetadataFingerprint(title, artist, album string, durationSec float64) string { + t := NormalizeText(title) + a := NormalizeText(artist) + al := NormalizeText(album) + + durKey := "" + if durationSec > 0 { + // Round to nearest 2-second bucket to tolerate minor encoding duration differences + bucket := int64(math.Round(durationSec/2.0) * 2) + durKey = fmt.Sprintf("%d", bucket) + } + + parts := []string{t, a, al, durKey} + return strings.Trim(strings.Join(parts, "|"), "|") +} + +// CheckDuplicate queries MongoDB to find if a track already exists by file_unique_id or fingerprint. +func (s *DedupService) CheckDuplicate(ctx context.Context, fileUniqueID, fingerprint string) (*models.Track, bool, error) { + if s.col == nil { + return nil, false, nil + } + + var orConditions []bson.M + if fileUniqueID != "" { + orConditions = append(orConditions, bson.M{"telegram.file_unique_id": fileUniqueID}) + orConditions = append(orConditions, bson.M{"_id": fileUniqueID}) + } + if fingerprint != "" { + orConditions = append(orConditions, bson.M{"fingerprint": fingerprint}) + } + + if len(orConditions) == 0 { + return nil, false, nil + } + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": orConditions, + } + + var existing models.Track + err := s.col.FindOne(ctx, filter).Decode(&existing) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, false, nil + } + return nil, false, err + } + + return &existing, true, nil +} + +// IsDuplicate checks whether a track is already present in the database. +func (s *DedupService) IsDuplicate(ctx context.Context, fileUniqueID, title, artist, album string, durationSec float64) bool { + fp := MetadataFingerprint(title, artist, album, durationSec) + _, found, _ := s.CheckDuplicate(ctx, fileUniqueID, fp) + return found +} diff --git a/internal/services/enrichment_service.go b/internal/services/enrichment_service.go new file mode 100644 index 0000000..d8c985a --- /dev/null +++ b/internal/services/enrichment_service.go @@ -0,0 +1,348 @@ +package services + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/logger" + "streamgo/internal/models" +) + +var logEnrich = logger.New("enrichment") + +// MediaDownloader downloads partial media bytes for an indexed file_id. +type MediaDownloader interface { + DownloadPartialByFileID(ctx context.Context, fileID string, maxBytes int64, w io.Writer) error +} + +// EnrichmentService runs background worker goroutines to enrich track metadata. +type EnrichmentService struct { + tracksCol *mongo.Collection + artistsCol *mongo.Collection + albumsCol *mongo.Collection + + coverSearch *CoverSearchService + lyricsSvc *LyricsEnrichmentService + downloader MediaDownloader + + workQueue chan string + wg sync.WaitGroup +} + +// NewEnrichmentService creates a new EnrichmentService. +func NewEnrichmentService( + db *database.Client, + coverSearch *CoverSearchService, + lyricsSvc *LyricsEnrichmentService, +) *EnrichmentService { + var tracksCol, artistsCol, albumsCol *mongo.Collection + if db != nil { + tracksCol = db.Collection("audioTracks") + artistsCol = db.Collection("artists") + albumsCol = db.Collection("albums") + } + + return &EnrichmentService{ + tracksCol: tracksCol, + artistsCol: artistsCol, + albumsCol: albumsCol, + coverSearch: coverSearch, + lyricsSvc: lyricsSvc, + workQueue: make(chan string, 500), + } +} + +// SetDownloader configures the MediaDownloader implementation for streaming partial media. +func (s *EnrichmentService) SetDownloader(d MediaDownloader) { + s.downloader = d +} + +// TriggerEnrich pushes a track ID to the priority enrichment queue. +func (s *EnrichmentService) TriggerEnrich(trackID string) { + select { + case s.workQueue <- trackID: + default: + logEnrich.Warnf("enrichment queue full, dropping immediate trigger for %s", trackID) + } +} + +// Start launches the background worker goroutines and the periodic poller. +func (s *EnrichmentService) Start(ctx context.Context, numWorkers int) { + if s.tracksCol == nil { + return + } + if numWorkers <= 0 { + numWorkers = 2 + } + + logEnrich.Infof("Starting background enrichment pool with %d workers", numWorkers) + + for i := 0; i < numWorkers; i++ { + s.wg.Add(1) + go s.workerLoop(ctx, i+1) + } + + // Periodic polling loop finding unenriched tracks + go s.pollLoop(ctx) +} + +func (s *EnrichmentService) pollLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.enqueueUnenrichedTracks(ctx) + } + } +} + +func (s *EnrichmentService) enqueueUnenrichedTracks(ctx context.Context) { + filter := bson.M{ + "enriched": bson.M{"$ne": true}, + "deleted": bson.M{"$ne": true}, + } + opts := options.Find().SetLimit(50).SetProjection(bson.M{"_id": 1}) + + cursor, err := s.tracksCol.Find(ctx, filter, opts) + if err != nil { + return + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var doc struct { + ID string `bson:"_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.ID != "" { + select { + case s.workQueue <- doc.ID: + default: + return + } + } + } +} + +func (s *EnrichmentService) workerLoop(ctx context.Context, workerID int) { + defer s.wg.Done() + + for { + select { + case <-ctx.Done(): + return + case trackID := <-s.workQueue: + s.enrichSingleTrack(ctx, trackID) + } + } +} + +func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID string) { + var track models.Track + err := s.tracksCol.FindOne(ctx, bson.M{"_id": trackID}).Decode(&track) + if err != nil { + return + } + + title := track.Audio.Title + artist := track.EffectiveArtist() + album := track.Audio.Album + durationSec := track.Audio.DurationSec + + nowTs := float64(time.Now().Unix()) + updateFields := bson.M{ + "enriched": true, + "enriched_at": nowTs, + "updated_at": nowTs, + } + + // 1. Partial Media Download & MediaInfo extraction (matching Python StreamXBot) + if s.downloader != nil && track.Telegram.FileID != "" { + tmpFile, err := os.CreateTemp("", fmt.Sprintf("streamgo_mi_%s_*.part", track.ID)) + if err == nil { + tmpPath := tmpFile.Name() + dlErr := s.downloader.DownloadPartialByFileID(ctx, track.Telegram.FileID, 2_000_000, tmpFile) + tmpFile.Close() + defer os.Remove(tmpPath) + + if dlErr == nil || dlErr == io.EOF { + // A. Compute Content Hash (sha256 prefix of downloaded chunk) + if ch, err := SHA256PrefixFile(tmpPath, 10*1024*1024); err == nil && ch != "" { + updateFields["content_hash"] = ch + } + + // B. Run MediaInfo + if output, err := RunMediaInfo(tmpPath); err == nil && output != "" { + meta := ParseMediaInfo(output, track.Audio.DurationSec, track.Telegram.FileSize) + if meta != nil { + if meta.Title != "" { + title = meta.Title + updateFields["audio.title"] = meta.Title + } + if meta.Artist != "" { + artist = meta.Artist + updateFields["audio.artist"] = meta.Artist + } + if len(meta.Artists) > 0 { + updateFields["audio.artists"] = meta.Artists + } + if meta.Album != "" { + album = meta.Album + updateFields["audio.album"] = meta.Album + } + if meta.Composer != "" { + updateFields["audio.composer"] = meta.Composer + } + if meta.Label != "" { + updateFields["audio.label"] = meta.Label + } + if meta.Genre != "" { + updateFields["audio.genre"] = meta.Genre + } + if meta.Year != nil { + updateFields["audio.year"] = *meta.Year + } + if meta.DurationSec > 0 { + durationSec = meta.DurationSec + updateFields["audio.duration_sec"] = meta.DurationSec + } + if meta.Type != "" { + updateFields["audio.type"] = meta.Type + } + if meta.BitDepth != nil { + updateFields["audio.bit_depth"] = *meta.BitDepth + } + if meta.BitrateKbps != nil { + updateFields["audio.bitrate_kbps"] = *meta.BitrateKbps + } + if meta.SamplingRateHz != nil { + updateFields["audio.sampling_rate_hz"] = *meta.SamplingRateHz + } + if meta.AlbumID != "" { + updateFields["audio.album_id"] = meta.AlbumID + } + } + } else if err != nil { + logEnrich.Warnf("mediainfo failed on %s: %v", trackID, err) + } + } else { + logEnrich.Warnf("partial download failed for track %s: %v", trackID, dlErr) + } + } + } + + if title == "" { + return + } + + // 2. Calculate and set metadata fingerprint (will include resolved album) + fp := BuildMetadataFingerprint(title, artist, album, durationSec) + if fp != "" { + updateFields["fingerprint"] = fp + } + + // 3. Ensure album_id is set if album is present + if album != "" && updateFields["audio.album_id"] == nil && track.Audio.AlbumID == "" { + aid := GenerateAlbumID(album, nil) + if aid != "" { + updateFields["audio.album_id"] = aid + } + } + + // 4. Fetch cover art if missing (stores in spotify, NEVER in audio) + if track.Spotify.CoverURL == "" { + coverURL, _, err := s.coverSearch.FindBestCover(ctx, title, artist, album) + if err == nil && coverURL != "" { + updateFields["spotify.cover_url"] = coverURL + updateFields["spotify.big_cover_url"] = coverURL + updateFields["spotify.cover_source"] = "itunes" + } + } + + // 5. Fetch artist avatar + if artist != "" && track.Spotify.ArtistAvatar == "" { + artistCover, _ := s.coverSearch.FindArtistAvatar(ctx, artist) + if artistCover != "" { + updateFields["spotify.artist_avatar"] = artistCover + } + } + + // 6. Fetch lyrics if missing (stores in root lyrics & lyrics_cache, NEVER in audio) + if track.Lyrics == "" { + res, err := s.lyricsSvc.FetchLyrics(ctx, title, artist, album) + if err == nil && res != nil && res.Lyrics != "" { + updateFields["lyrics"] = res.Lyrics + updateFields["lyrics_cache"] = bson.M{ + "kind": "lrc", + "source": "lrclib", + "text": res.Lyrics, + "updated_at": nowTs, + } + } + } + + // 7. Ensure root titles is populated + if len(track.Titles) == 0 { + updateFields["titles"] = bson.M{ + "original": title, + } + } + + // 8. Clean unsetting of legacy / erroneous fields inside audio subdocument + unsetFields := bson.M{ + "audio.titles": "", + "audio.lyrics": "", + "audio.cover_url": "", + "audio.file_size": "", + "audio.mime_type": "", + "enriching": "", + "enrichment_error": "", + "enrichment_started_at": "", + } + + // 9. Update track document cleanly + _, err = s.tracksCol.UpdateOne(ctx, bson.M{"_id": trackID}, bson.M{ + "$set": updateFields, + "$unset": unsetFields, + }) + if err != nil { + logEnrich.Errorf("Failed to update track %s in mongo: %v", trackID, err) + return + } + + // 10. Update artist entity + if artist != "" && s.artistsCol != nil { + artistID := "artist_" + strings.ToLower(NormalizeText(artist)) + artistCover, _ := s.coverSearch.FindArtistAvatar(ctx, artist) + artistUpdate := bson.M{ + "$setOnInsert": bson.M{ + "_id": artistID, + "name": artist, + "match_artist": strings.ToLower(artist), + "created_at": nowTs, + }, + "$set": bson.M{ + "updated_at": nowTs, + }, + } + if artistCover != "" { + artistUpdate["$set"].(bson.M)["cover_url"] = artistCover + } + _, _ = s.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, artistUpdate, options.UpdateOne().SetUpsert(true)) + } + + logEnrich.Infof("Enriched track %s (%s - %s)", trackID, artist, title) +} diff --git a/internal/services/internal_services_test.go b/internal/services/internal_services_test.go new file mode 100644 index 0000000..6f3cc61 --- /dev/null +++ b/internal/services/internal_services_test.go @@ -0,0 +1,90 @@ +package services + +import ( + "context" + "testing" + + "streamgo/internal/config" +) + +func TestStripNoise(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Song Title (Official Video)", "Song Title"}, + {"Track Name [Remastered 2021]", "Track Name"}, + {"Another Track [Lyric Video]", "Another Track"}, + {"Track (Live at Tokyo Dome)", "Track"}, + {"Simple Song", "Simple Song"}, + {"Official Audio Track", "Track"}, + } + + for _, tc := range tests { + result := StripNoise(tc.input) + if result != tc.expected { + t.Errorf("StripNoise(%q) = %q; expected %q", tc.input, result, tc.expected) + } + } +} + +func TestMetadataFingerprint(t *testing.T) { + // Alphaville Forever Young test matching original source + fpOriginal := MetadataFingerprint("Forever Young", "Alphaville", "Disc 1", 226) + expectedOriginal := "forever young|alphaville|disc 1|226" + if fpOriginal != expectedOriginal { + t.Errorf("expected %q, got %q", expectedOriginal, fpOriginal) + } + + fp1 := MetadataFingerprint("Nandemonaiya (movie ver.)", "RADWIMPS", "", 344.2) + fp2 := MetadataFingerprint("Nandemonaiya", "Radwimps", "", 344.8) + + // Both should not be empty + if fp1 == "" || fp2 == "" { + t.Fatal("Fingerprints should not be empty") + } + + // RADWIMPS and Radwimps should match lower-cased + if NormalizeText("RADWIMPS") != NormalizeText("Radwimps") { + t.Errorf("expected NormalizeText to match case-insensitively") + } + + // Check 2-second duration bucketing within same rounding slice + fpClose1 := MetadataFingerprint("Song", "Artist", "Album", 120.1) + fpClose2 := MetadataFingerprint("Song", "Artist", "Album", 120.8) + if fpClose1 != fpClose2 { + t.Errorf("expected %q == %q within 2-second bucket", fpClose1, fpClose2) + } +} + +func TestAccessFilter(t *testing.T) { + cfg := &config.Config{ + FilterMode: FilterModeGroupOnly, + ChannelID: -1004300252384, + DumpChannelID: -1009999999999, + } + + filter := NewAccessFilter(cfg, nil) + ctx := context.Background() + + // ChannelID should be allowed + if !filter.IsChatAllowed(ctx, -1004300252384) { + t.Errorf("expected channel %d to be allowed in GroupOnly mode", -1004300252384) + } + + // DumpChannelID should be allowed + if !filter.IsChatAllowed(ctx, -1009999999999) { + t.Errorf("expected dump channel %d to be allowed in GroupOnly mode", -1009999999999) + } + + // Random unauthorized chat should be blocked + if filter.IsChatAllowed(ctx, -1001234567890) { + t.Errorf("expected unauthorized chat to be blocked in GroupOnly mode") + } + + // Switch to Anyone mode + cfg.FilterMode = FilterModeAnyone + if !filter.IsChatAllowed(ctx, -1001234567890) { + t.Errorf("expected any chat to be allowed in FilterModeAnyone") + } +} diff --git a/internal/services/lyrics_enrichment_service.go b/internal/services/lyrics_enrichment_service.go new file mode 100644 index 0000000..a84e8b7 --- /dev/null +++ b/internal/services/lyrics_enrichment_service.go @@ -0,0 +1,105 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "streamgo/internal/logger" +) + +var logLyrics = logger.New("lyrics") + +// LyricsResult bundles resolved lyrics and alternative multilingual titles. +type LyricsResult struct { + Lyrics string + SyncedLyrics string + Titles map[string]any +} + +// LyricsEnrichmentService fetches lyrics and alternative titles from LRCLIB and Musixmatch. +type LyricsEnrichmentService struct { + httpClient *http.Client +} + +// NewLyricsEnrichmentService creates a new LyricsEnrichmentService. +func NewLyricsEnrichmentService() *LyricsEnrichmentService { + return &LyricsEnrichmentService{ + httpClient: &http.Client{ + Timeout: 8 * time.Second, + }, + } +} + +// FetchLyrics searches for lyrics and romanized/localized titles. +func (s *LyricsEnrichmentService) FetchLyrics(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + cleanTitle := StripNoise(title) + cleanArtist := StripNoise(artist) + cleanAlbum := StripNoise(album) + + if cleanTitle == "" { + return nil, fmt.Errorf("title cannot be empty") + } + + // 1. Query LRCLIB API + res, err := s.queryLRCLIB(ctx, cleanTitle, cleanArtist, cleanAlbum) + if err == nil && res != nil { + return res, nil + } + + return nil, fmt.Errorf("no lyrics found for %s - %s", artist, title) +} + +func (s *LyricsEnrichmentService) queryLRCLIB(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + apiURL := fmt.Sprintf("https://lrclib.net/api/get?track_name=%s&artist_name=%s", + url.QueryEscape(title), + url.QueryEscape(artist), + ) + if album != "" { + apiURL += fmt.Sprintf("&album_name=%s", url.QueryEscape(album)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "StreamGO/1.0") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("lrclib status: %d", resp.StatusCode) + } + + var payload struct { + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + bestLyrics := payload.SyncedLyrics + if bestLyrics == "" { + bestLyrics = payload.PlainLyrics + } + + if bestLyrics == "" { + return nil, fmt.Errorf("empty lyrics from lrclib") + } + + return &LyricsResult{ + Lyrics: bestLyrics, + SyncedLyrics: payload.SyncedLyrics, + }, nil +} diff --git a/internal/services/mediainfo.go b/internal/services/mediainfo.go new file mode 100644 index 0000000..6a95f8e --- /dev/null +++ b/internal/services/mediainfo.go @@ -0,0 +1,534 @@ +package services + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/sha256" + "fmt" + "io" + "math" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" + + "streamgo/internal/models" +) + +var ( + artistSplitRegex = regexp.MustCompile(`(?i)\s*(?:,|/|&|\s+and\s+|\s+x\s+|\s+feat\.\s+|\s+feat\s+|\s+ft\.\s+|\s+ft\s+)\s*`) + albumSlugRegex = regexp.MustCompile(`(?i)[^a-z0-9 ]+`) + yearRegex = regexp.MustCompile(`\b(19\d{2}|20\d{2})\b`) + durationHourRe = regexp.MustCompile(`(?i)(\d+)\s*h`) + durationMinRe = regexp.MustCompile(`(?i)(\d+)\s*min`) + durationSecRe = regexp.MustCompile(`(?i)(\d+)\s*s`) + durationMsRe = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*ms`) + floatNumberRe = regexp.MustCompile(`(\d+(?:\.\d+)?)`) +) + +// RunMediaInfo executes the system mediainfo binary on the provided file path. +func RunMediaInfo(filePath string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "mediainfo", filePath) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("mediainfo error: %w (stderr: %s)", err, stderr.String()) + } + + out := stdout.String() + if out == "" { + out = stderr.String() + } + return out, nil +} + +// ParseMediaInfo parses the raw textual output of mediainfo into AudioMeta. +func ParseMediaInfo(output string, fallbackDurationSec int32, fileSize int64) *models.AudioMeta { + sections := parseMediaInfoSections(output) + general := sections["general"] + if general == nil { + general = make(map[string]string) + } + audio := sections["audio"] + if audio == nil { + audio = make(map[string]string) + } + + title := pickBestTitle(general, audio) + album := getFirst(general, "album", "original source form/name") + artist := getFirst(general, "performer", "album/performer", "director", "artist") + if artist == "" { + artist = audio["performer"] + } + composer := general["composer"] + label := getFirst(general, "label", "publisher") + genre := general["genre"] + recordedDate := getFirst(general, "recorded date", "recorded date ") + + // Format / Type normalization + rawFormat := strings.ToLower(getFirst(audio, "format", "general format")) + if rawFormat == "" { + rawFormat = strings.ToLower(general["format"]) + } + fileType := normalizeAudioFormat(rawFormat) + + bitDepth := parseBitDepth(audio["bit depth"]) + bitrateKbps := parseBitrateKbps(getFirst(audio, "bit rate", "overall bit rate", general["overall bit rate"])) + samplingRateHz := parseSamplingRateHz(audio["sampling rate"]) + year := parseYear(recordedDate) + + durationSec := parseDurationSeconds(getFirst(general, "duration", audio["duration"])) + if durationSec == 0 && fallbackDurationSec > 0 { + durationSec = fallbackDurationSec + } + + // Adjust duration if partial chunk truncated CBR / uncompressed stream + if fileSize > 3_000_000 { + var calcDur int32 + if fileType == "pcm" || fileType == "wav" || (samplingRateHz != nil && bitDepth != nil) { + var sr int64 = 44100 + if samplingRateHz != nil && *samplingRateHz > 0 { + sr = int64(*samplingRateHz) + } + var bd int64 = 16 + if bitDepth != nil && *bitDepth > 0 { + bd = int64(*bitDepth) + } + channels := int64(2) + chStr := strings.ToLower(getFirst(audio, "channel(s)", general["channel(s)"])) + if strings.Contains(chStr, "1 channel") { + channels = 1 + } + byteRate := (sr * channels * bd) / 8 + if byteRate > 0 { + calcDur = int32(math.Round(float64(fileSize-44) / float64(byteRate))) + } + } + if calcDur == 0 && bitrateKbps != nil && *bitrateKbps > 0 { + calcDur = int32(math.Round(float64(fileSize*8) / float64(*bitrateKbps*1000))) + } + + if calcDur > 0 && (durationSec == 0 || (calcDur > durationSec && (durationSec <= 20 || float64(calcDur)/math.Max(float64(durationSec), 1) >= 2))) { + durationSec = calcDur + } + } + + // Split artists + artists := SplitArtists(artist) + + // Album ID + var albumID string + if album != "" { + albumID = GenerateAlbumID(album, year) + } + + meta := &models.AudioMeta{ + Title: title, + Album: album, + Artist: artist, + Composer: composer, + Label: label, + Genre: genre, + Year: year, + DurationSec: durationSec, + Type: fileType, + BitDepth: bitDepth, + BitrateKbps: bitrateKbps, + SamplingRateHz: samplingRateHz, + Artists: artists, + AlbumID: albumID, + } + + return meta +} + +func parseMediaInfoSections(output string) map[string]map[string]string { + sections := make(map[string]map[string]string) + var current string + + for _, rawLine := range strings.Split(output, "\n") { + line := strings.TrimRight(rawLine, "\r\n") + header := strings.ToLower(strings.TrimSpace(line)) + switch header { + case "general", "audio", "video", "text", "image", "menu": + current = header + if _, exists := sections[current]; !exists { + sections[current] = make(map[string]string) + } + continue + } + + if current == "" || !strings.Contains(line, " : ") { + continue + } + + parts := strings.SplitN(line, " : ", 2) + k := strings.ToLower(strings.TrimSpace(parts[0])) + v := strings.TrimSpace(parts[1]) + if k == "" || v == "" { + continue + } + + if _, exists := sections[current][k]; !exists { + sections[current][k] = v + } + } + + return sections +} + +func pickBestTitle(general, audio map[string]string) string { + t1 := strings.TrimSpace(getFirst(general, "title", "track name", "track name/position")) + t2 := strings.TrimSpace(getFirst(audio, "title", "track name")) + + if t1 != "" && !isJunkTitle(t1) { + return t1 + } + if t2 != "" && !isJunkTitle(t2) { + return t2 + } + if t1 != "" { + return t1 + } + return t2 +} + +func isJunkTitle(title string) bool { + t := strings.ToLower(strings.TrimSpace(title)) + if t == "" { + return true + } + junk := []string{ + "core media audio", + "mpeg audio", + "iso media file produced by google inc.", + "lavf", + } + for _, j := range junk { + if strings.Contains(t, j) { + return true + } + } + return false +} + +func normalizeAudioFormat(format string) string { + f := strings.ToLower(strings.TrimSpace(format)) + switch { + case strings.Contains(f, "flac"): + return "flac" + case strings.Contains(f, "mpeg") || strings.Contains(f, "layer 3") || f == "mp3": + return "mp3" + case strings.Contains(f, "aac") || strings.Contains(f, "alac") || strings.Contains(f, "mp4"): + return "m4a" + case strings.Contains(f, "ogg") || strings.Contains(f, "vorbis") || strings.Contains(f, "opus"): + return "ogg" + case strings.Contains(f, "wave") || strings.Contains(f, "pcm") || f == "wav": + return "wav" + default: + if idx := strings.Index(f, " "); idx != -1 { + return f[:idx] + } + return f + } +} + +func parseBitDepth(val string) *int32 { + if val == "" { + return nil + } + m := floatNumberRe.FindString(val) + if m == "" { + return nil + } + n, err := strconv.ParseFloat(m, 64) + if err != nil { + return nil + } + v := int32(math.Round(n)) + return &v +} + +func parseBitrateKbps(val string) *int32 { + if val == "" { + return nil + } + s := strings.ToLower(compactDigits(val)) + m := floatNumberRe.FindString(s) + if m == "" { + return nil + } + n, err := strconv.ParseFloat(m, 64) + if err != nil { + return nil + } + var res int32 + if strings.Contains(s, "mb/s") { + res = int32(math.Round(n * 1000)) + } else if strings.Contains(s, "kb/s") { + res = int32(math.Round(n)) + } else if strings.Contains(s, "b/s") { + res = int32(math.Round(n / 1000)) + } else { + res = int32(math.Round(n)) + } + return &res +} + +func parseSamplingRateHz(val string) *int32 { + if val == "" { + return nil + } + s := strings.ToLower(compactDigits(val)) + m := floatNumberRe.FindString(s) + if m == "" { + return nil + } + n, err := strconv.ParseFloat(m, 64) + if err != nil { + return nil + } + var res int32 + if strings.Contains(s, "khz") { + res = int32(math.Round(n * 1000)) + } else { + res = int32(math.Round(n)) + } + return &res +} + +func parseYear(val string) *int32 { + if val == "" { + return nil + } + m := yearRegex.FindString(val) + if m == "" { + return nil + } + n, err := strconv.Atoi(m) + if err != nil || n < 1000 || n > 2100 { + return nil + } + v := int32(n) + return &v +} + +func parseDurationSeconds(val string) int32 { + if val == "" { + return 0 + } + s := strings.ToLower(val) + var total int32 + + if m := durationHourRe.FindStringSubmatch(s); len(m) > 1 { + h, _ := strconv.Atoi(m[1]) + total += int32(h * 3600) + } + if m := durationMinRe.FindStringSubmatch(s); len(m) > 1 { + min, _ := strconv.Atoi(m[1]) + total += int32(min * 60) + } + if m := durationSecRe.FindStringSubmatch(s); len(m) > 1 { + sec, _ := strconv.Atoi(m[1]) + total += int32(sec) + } + if total > 0 { + return total + } + if m := durationMsRe.FindStringSubmatch(s); len(m) > 1 { + ms, _ := strconv.ParseFloat(m[1], 64) + return int32(math.Round(ms / 1000)) + } + return 0 +} + +func compactDigits(s string) string { + // Remove space between digits: e.g. "88 200" -> "88200" + var sb strings.Builder + runes := []rune(strings.TrimSpace(s)) + for i := 0; i < len(runes); i++ { + if runes[i] == ' ' && i > 0 && i < len(runes)-1 && unicode.IsDigit(runes[i-1]) && unicode.IsDigit(runes[i+1]) { + continue + } + sb.WriteRune(runes[i]) + } + return sb.String() +} + +func getFirst(m map[string]string, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +// SplitArtists splits a multi-artist string into individual artist tokens. +func SplitArtists(value string) []string { + raw := strings.TrimSpace(value) + if raw == "" { + return []string{} + } + raw = strings.ReplaceAll(raw, "(", " ") + raw = strings.ReplaceAll(raw, ")", " ") + raw = strings.ReplaceAll(raw, "[", " ") + raw = strings.ReplaceAll(raw, "]", " ") + + parts := artistSplitRegex.Split(raw, -1) + out := make([]string, 0, len(parts)) + seen := make(map[string]bool) + + for _, p := range parts { + token := strings.Trim(strings.TrimSpace(p), " .") + if token == "" { + continue + } + key := strings.ToLower(token) + if seen[key] { + continue + } + seen[key] = true + out = append(out, token) + } + return out +} + +// Slugify normalizes an album title into a URL and ID friendly slug. +func Slugify(value string) string { + raw := strings.ToLower(strings.TrimSpace(value)) + if raw == "" { + return "" + } + s := strings.ReplaceAll(raw, "÷", " divide ") + s = strings.ReplaceAll(s, "&", " and ") + s = strings.ReplaceAll(s, "+", " plus ") + + // NFKD normalization to ASCII + t := transform.Chain(norm.NFKD, transform.RemoveFunc(func(r rune) bool { + return unicode.Is(unicode.Mn, r) // mn: nonspacing marks + })) + ascii, _, _ := transform.String(t, s) + + // Keep only alphanumeric and space + ascii = albumSlugRegex.ReplaceAllString(ascii, " ") + tokens := strings.Fields(ascii) + s = strings.Join(tokens, "_") + s = strings.Trim(s, "_") + + if s != "" { + return s + } + + h := sha1.Sum([]byte(raw)) + return fmt.Sprintf("u_%x", h[:8]) +} + +// GenerateAlbumID builds the canonical album identifier: album__ or album_. +func GenerateAlbumID(album string, year *int32) string { + b := Slugify(album) + if b == "" { + return "" + } + if year != nil && *year >= 1000 && *year <= 2100 { + return fmt.Sprintf("album_%s_%d", b, *year) + } + return fmt.Sprintf("album_%s", b) +} + +// SHA256PrefixFile hashes up to maxBytes of the file at filePath and returns "sha256:". +func SHA256PrefixFile(filePath string, maxBytes int64) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + _, err = io.CopyN(h, f, maxBytes) + if err != nil && err != io.EOF { + return "", err + } + + return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil +} + +// BuildMetadataFingerprint creates the canonical fingerprint string matching Python StreamXBot. +func BuildMetadataFingerprint(title, artist, album string, durationSec int32) string { + t := normalizeText(title) + a := normalizeText(artist) + al := normalizeText(album) + + durKey := "" + if durationSec > 0 { + bucket := int32(2) + durKey = strconv.Itoa(int(int32(math.Round(float64(durationSec)/float64(bucket))) * bucket)) + } + + parts := []string{t, a, al, durKey} + res := strings.Join(parts, "|") + return strings.Trim(res, "|") +} + +func normalizeText(val string) string { + s := strings.ToLower(val) + re := regexp.MustCompile(`[^a-z0-9]+`) + s = re.ReplaceAllString(s, " ") + return strings.Join(strings.Fields(s), " ") +} + +// NormalizeMimeType ensures audio mime types are standardized matching Python StreamXBot. +func NormalizeMimeType(mime string, fileName string) string { + if fileName != "" { + fn := strings.ToLower(strings.TrimSpace(fileName)) + switch { + case strings.HasSuffix(fn, ".wav") || strings.HasSuffix(fn, ".wave"): + return "audio/wav" + case strings.HasSuffix(fn, ".flac"): + return "audio/flac" + case strings.HasSuffix(fn, ".mp3"): + return "audio/mpeg" + case strings.HasSuffix(fn, ".m4a"): + return "audio/mp4" + case strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".opus"): + return "audio/ogg" + case strings.HasSuffix(fn, ".aac"): + return "audio/aac" + } + } + + if mime == "" { + return "audio/mpeg" + } + + raw := strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0])) + switch { + case raw == "audio/flac" || raw == "audio/x-flac" || strings.HasSuffix(raw, "/x-flac"): + return "audio/flac" + case raw == "audio/wav" || raw == "audio/x-wav" || raw == "audio/wave": + return "audio/wav" + case raw == "audio/mp3" || raw == "audio/mpeg": + return "audio/mpeg" + case raw == "audio/m4a" || raw == "audio/x-m4a" || raw == "audio/mp4": + return "audio/mp4" + case raw == "audio/ogg" || raw == "application/ogg": + return "audio/ogg" + case raw == "audio/aac": + return "audio/aac" + default: + return raw + } +} diff --git a/internal/services/mediainfo_test.go b/internal/services/mediainfo_test.go new file mode 100644 index 0000000..8963dcc --- /dev/null +++ b/internal/services/mediainfo_test.go @@ -0,0 +1,129 @@ +package services + +import ( + "testing" +) + +func TestParseMediaInfoWillow(t *testing.T) { + sampleOutput := `General +Complete name : /tmp/willow.part +Format : FLAC +Format/Info : Free Lossless Audio Codec +File size : 72.5 MiB +Duration : 3 min 34 s +Overall bit rate mode : Variable +Overall bit rate : 2 708 kb/s +Album : evermore (deluxe version) +Track name : willow +Track name/Position : 1 +Track name/Total : 17 +Performer : Taylor Swift +Composer : Taylor Swift / Aaron Dessner +Publisher : Taylor Swift +Genre : Alternative & Indie, Pop/Rock, Rock +Recorded date : 2020-12-11 +ISRC : USUG12004457 +Cover : Yes +Cover type : Cover +Cover MIME : image/jpeg + +Audio +Format : FLAC +Format/Info : Free Lossless Audio Codec +Duration : 3 min 34 s +Bit rate mode : Variable +Bit rate : 72 kb/s +Channel(s) : 2 channels +Channel layout : L R +Sampling rate : 88.2 kHz +Bit depth : 24 bits +Compression mode : Lossless +Stream size : 72.5 MiB (100%) +Writing library : libFLAC 1.3.2 (UTC 2017-01-01) +` + + meta := ParseMediaInfo(sampleOutput, 0, 72455262) + if meta == nil { + t.Fatal("expected non-nil AudioMeta") + } + + if meta.Title != "willow" { + t.Errorf("expected title 'willow', got '%s'", meta.Title) + } + if meta.Album != "evermore (deluxe version)" { + t.Errorf("expected album 'evermore (deluxe version)', got '%s'", meta.Album) + } + if meta.Artist != "Taylor Swift" { + t.Errorf("expected artist 'Taylor Swift', got '%s'", meta.Artist) + } + if meta.Composer != "Taylor Swift / Aaron Dessner" { + t.Errorf("expected composer 'Taylor Swift / Aaron Dessner', got '%s'", meta.Composer) + } + if meta.Label != "Taylor Swift" { + t.Errorf("expected label 'Taylor Swift', got '%s'", meta.Label) + } + if meta.Genre != "Alternative & Indie, Pop/Rock, Rock" { + t.Errorf("expected genre 'Alternative & Indie, Pop/Rock, Rock', got '%s'", meta.Genre) + } + if meta.Year == nil || *meta.Year != 2020 { + t.Errorf("expected year 2020, got %v", meta.Year) + } + if meta.DurationSec != 214 { + t.Errorf("expected duration_sec 214, got %d", meta.DurationSec) + } + if meta.Type != "flac" { + t.Errorf("expected type 'flac', got '%s'", meta.Type) + } + if meta.BitDepth == nil || *meta.BitDepth != 24 { + t.Errorf("expected bit_depth 24, got %v", meta.BitDepth) + } + if meta.SamplingRateHz == nil || *meta.SamplingRateHz != 88200 { + t.Errorf("expected sampling_rate_hz 88200, got %v", meta.SamplingRateHz) + } + if meta.AlbumID != "album_evermore_deluxe_version_2020" { + t.Errorf("expected album_id 'album_evermore_deluxe_version_2020', got '%s'", meta.AlbumID) + } + if len(meta.Artists) != 1 || meta.Artists[0] != "Taylor Swift" { + t.Errorf("expected artists ['Taylor Swift'], got %v", meta.Artists) + } + + fp := BuildMetadataFingerprint(meta.Title, meta.Artist, meta.Album, meta.DurationSec) + if fp != "willow|taylor swift|evermore deluxe version|214" { + t.Errorf("expected fingerprint 'willow|taylor swift|evermore deluxe version|214', got '%s'", fp) + } +} + +func TestSplitArtists(t *testing.T) { + artists := SplitArtists("Taylor Swift, Aaron Dessner feat. Bon Iver / Jack Antonoff & HAIM and Marcus Mumford") + expected := []string{"Taylor Swift", "Aaron Dessner", "Bon Iver", "Jack Antonoff", "HAIM", "Marcus Mumford"} + + if len(artists) != len(expected) { + t.Fatalf("expected %d artists, got %d: %v", len(expected), len(artists), artists) + } + for i, v := range expected { + if artists[i] != v { + t.Errorf("expected artist[%d] == %s, got %s", i, v, artists[i]) + } + } +} + +func TestNormalizeMimeType(t *testing.T) { + tests := []struct { + mime string + fileName string + expected string + }{ + {"audio/x-flac", "01 - willow.flac", "audio/flac"}, + {"audio/flac", "song.flac", "audio/flac"}, + {"audio/mp3", "track.mp3", "audio/mpeg"}, + {"audio/x-m4a", "track.m4a", "audio/mp4"}, + {"audio/x-wav", "track.wav", "audio/wav"}, + } + + for _, tc := range tests { + got := NormalizeMimeType(tc.mime, tc.fileName) + if got != tc.expected { + t.Errorf("NormalizeMimeType(%s, %s) = %s, expected %s", tc.mime, tc.fileName, got, tc.expected) + } + } +} diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index 439fba9..a70aa31 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -304,9 +304,13 @@ func (s *StreamService) streamFromTelegram( flusher: flusher, } - downloader := s.tgService.Client.Downloader() - _, err = downloader.Download(s.tgService.API, location).Stream(ctx, rw) + worker := s.tgService.AcquireWorker() + defer s.tgService.ReleaseWorker(worker) + + downloader := worker.Client.Downloader() + _, err = downloader.Download(worker.API, location).Stream(ctx, rw) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) { logStream.Warnf("streaming ended with error for track %s: %v", track.ID, err) } } + diff --git a/internal/telegram/client.go b/internal/telegram/client.go index 626bbed..fc8c6c4 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -4,7 +4,11 @@ import ( "context" "errors" "fmt" + "io" + "strconv" + "strings" "sync" + "sync/atomic" "time" "github.com/gotd/td/telegram" @@ -17,120 +21,336 @@ import ( var log = logger.New("telegram") -// Service encapsulates the gotd MTProto client lifecycle. -type Service struct { - Config *config.Config +// ClientWorker represents a single authenticated MTProto client connection. +type ClientWorker struct { + ID int64 + FirstName string + Username string + Bot bool + Token string Client *telegram.Client API *tg.Client Downloader *downloader.Downloader + Workload int64 + Ready bool +} - mu sync.RWMutex - ready bool - self *tg.User - stopCancel context.CancelFunc +// Service encapsulates the MTProto multi-client pool and lifecycle. +type Service struct { + Config *config.Config + primaryWorker *ClientWorker + workers []*ClientWorker + mu sync.RWMutex + stopCancel context.CancelFunc + Dispatcher tg.UpdateDispatcher } -// New creates an unstarted Telegram service instance. +// New creates an unstarted Telegram multi-client pool. func New(cfg *config.Config) (*Service, error) { if cfg.ApiID <= 0 || cfg.ApiHash == "" { return nil, errors.New("API_ID and API_HASH are required to initialize Telegram client") } - opts := telegram.Options{ - NoUpdates: false, - } + dispatcher := tg.NewUpdateDispatcher() - client := telegram.NewClient(cfg.ApiID, cfg.ApiHash, opts) - - return &Service{ + svc := &Service{ Config: cfg, - Client: client, - API: client.API(), + workers: make([]*ClientWorker, 0), + Dispatcher: dispatcher, + } + + // 1. Create Primary Worker + primaryClient := telegram.NewClient(cfg.ApiID, cfg.ApiHash, telegram.Options{ + NoUpdates: false, + UpdateHandler: dispatcher, + }) + svc.primaryWorker = &ClientWorker{ + Token: cfg.BotToken, + Client: primaryClient, + API: primaryClient.API(), Downloader: downloader.NewDownloader(), - }, nil + } + svc.workers = append(svc.workers, svc.primaryWorker) + + + // 2. Create Secondary Multi-Client Workers + if cfg.MultiClients { + for i, tok := range cfg.MultiClientTokens { + tok = strings.TrimSpace(tok) + if tok == "" || tok == cfg.BotToken { + continue + } + workerClient := telegram.NewClient(cfg.ApiID, cfg.ApiHash, telegram.Options{ + NoUpdates: true, // Secondary download workers don't need update processing + }) + worker := &ClientWorker{ + Token: tok, + Client: workerClient, + API: workerClient.API(), + Downloader: downloader.NewDownloader(), + } + svc.workers = append(svc.workers, worker) + log.Infof("Registered multi-client worker #%d", i+1) + } + } + + return svc, nil } -// Start launches the Telegram client loop in the background and authenticates. +// Start launches all MTProto client workers concurrently. func (s *Service) Start(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) s.stopCancel = cancel readyChan := make(chan struct{}) - errChan := make(chan error, 1) - - go func() { - err := s.Client.Run(ctx, func(ctx context.Context) error { - // Handle authentication - authStatus, err := s.Client.Auth().Status(ctx) - if err != nil { - return fmt.Errorf("failed to check auth status: %w", err) - } + var once sync.Once - if !authStatus.Authorized { - if s.Config.BotToken != "" { - log.Info("authorizing with BOT_TOKEN...") - if _, err := s.Client.Auth().Bot(ctx, s.Config.BotToken); err != nil { - return fmt.Errorf("bot authentication failed: %w", err) - } - log.Info("bot authentication successful!") - } else { - log.Warn("client is not authorized and no BOT_TOKEN provided") + for _, w := range s.workers { + worker := w + go func() { + _ = worker.Client.Run(ctx, func(ctx context.Context) error { + // Authenticate + authStatus, err := worker.Client.Auth().Status(ctx) + if err != nil { + log.Warnf("Worker check auth failed: %v", err) + return err } - } - // Get authenticated user info - self, err := s.Client.Self(ctx) - if err == nil && self != nil { - s.mu.Lock() - s.self = self - s.ready = true - s.mu.Unlock() - log.Infof("connected as: %s (ID: %d, Bot: %v)", self.FirstName, self.ID, self.Bot) - } + if !authStatus.Authorized && worker.Token != "" { + log.Infof("Authorizing client with token (prefix: %s...)", worker.Token[:min(10, len(worker.Token))]) + if _, err := worker.Client.Auth().Bot(ctx, worker.Token); err != nil { + log.Warnf("Worker bot auth failed: %v", err) + return err + } + log.Info("Worker authentication successful!") + } - close(readyChan) + self, err := worker.Client.Self(ctx) + if err == nil && self != nil { + s.mu.Lock() + worker.ID = self.ID + worker.FirstName = self.FirstName + worker.Username = self.Username + worker.Bot = self.Bot + worker.Ready = true + s.mu.Unlock() + log.Infof("Connected client: %s (@%s, ID: %d, Bot: %v)", self.FirstName, self.Username, self.ID, self.Bot) + } - // Keep client running until context cancellation - <-ctx.Done() - return ctx.Err() - }) + if worker == s.primaryWorker { + once.Do(func() { + close(readyChan) + }) + } - if err != nil && !errors.Is(err, context.Canceled) { - errChan <- err - } - }() + <-ctx.Done() + return ctx.Err() + }) + }() + } - // Wait for connection or timeout + // Wait up to 25 seconds for primary client readiness select { case <-readyChan: + log.Infof("Telegram service active with %d client(s) ready", s.ReadyWorkerCount()) + return nil + case <-time.After(25 * time.Second): + log.Warn("Telegram service started with delayed initialization") return nil - case err := <-errChan: - return err - case <-time.After(15 * time.Second): - return fmt.Errorf("timeout waiting for telegram connection") case <-ctx.Done(): return ctx.Err() } } -// IsReady returns true if the client is connected and authorized. -func (s *Service) IsReady() bool { +// ReadyWorkerCount returns number of connected and ready workers. +func (s *Service) ReadyWorkerCount() int { s.mu.RLock() defer s.mu.RUnlock() - return s.ready + count := 0 + for _, w := range s.workers { + if w.Ready { + count++ + } + } + return count } -// Self returns the authenticated Telegram user/bot profile. -func (s *Service) Self() *tg.User { +// PrimaryWorker returns the main bot worker. +func (s *Service) PrimaryWorker() *ClientWorker { s.mu.RLock() defer s.mu.RUnlock() - return s.self + return s.primaryWorker +} + +// AcquireWorker selects the best ready worker with the lowest workload (load-balancing). +func (s *Service) AcquireWorker() *ClientWorker { + s.mu.RLock() + defer s.mu.RUnlock() + + var best *ClientWorker + minLoad := int64(1<<62 - 1) + + for _, w := range s.workers { + if !w.Ready { + continue + } + load := atomic.LoadInt64(&w.Workload) + if best == nil || load < minLoad { + best = w + minLoad = load + } + } + + if best == nil { + best = s.primaryWorker + } + + if best != nil { + atomic.AddInt64(&best.Workload, 1) + } + return best +} + +// AcquireWorkerForBot attempts to acquire a specific worker by Telegram bot user ID. +func (s *Service) AcquireWorkerForBot(botID string) *ClientWorker { + botID = strings.TrimSpace(botID) + if botID != "" { + s.mu.RLock() + for _, w := range s.workers { + if w.Ready && strconv.FormatInt(w.ID, 10) == botID { + s.mu.RUnlock() + atomic.AddInt64(&w.Workload, 1) + return w + } + } + s.mu.RUnlock() + } + return s.AcquireWorker() +} + +// ReleaseWorker decrements active download workload for a worker. +func (s *Service) ReleaseWorker(w *ClientWorker) { + if w != nil { + atomic.AddInt64(&w.Workload, -1) + } } -// Stop terminates the Telegram background loop. +// Stop gracefully stops all Telegram client workers. func (s *Service) Stop() { if s.stopCancel != nil { - log.Info("stopping client...") s.stopCancel() } } + +// IsReady returns true if at least the primary client is connected. +func (s *Service) IsReady() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.primaryWorker != nil && s.primaryWorker.Ready +} + +// Self returns primary bot user info. +func (s *Service) Self() *tg.User { + s.mu.RLock() + defer s.mu.RUnlock() + if s.primaryWorker != nil && s.primaryWorker.Ready { + return &tg.User{ + ID: s.primaryWorker.ID, + FirstName: s.primaryWorker.FirstName, + Username: s.primaryWorker.Username, + Bot: s.primaryWorker.Bot, + } + } + return nil +} + +// StatusString returns summary of connected bot clients for /health. +func (s *Service) StatusString() string { + s.mu.RLock() + defer s.mu.RUnlock() + + readyWorkers := 0 + var names []string + for _, w := range s.workers { + if w.Ready { + readyWorkers++ + if w.Username != "" { + names = append(names, fmt.Sprintf("@%s", w.Username)) + } else if w.FirstName != "" { + names = append(names, w.FirstName) + } + } + } + + if readyWorkers == 0 { + return "connecting" + } + return fmt.Sprintf("connected (%s)", strings.Join(names, ", ")) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type partialWriter struct { + w io.Writer + remain int64 +} + +func (pw *partialWriter) Write(p []byte) (int, error) { + if pw.remain <= 0 { + return 0, io.EOF + } + + toWrite := p + if int64(len(toWrite)) > pw.remain { + toWrite = toWrite[:pw.remain] + } + + n, err := pw.w.Write(toWrite) + pw.remain -= int64(n) + if err != nil { + return n, err + } + if pw.remain <= 0 { + return n, io.EOF + } + return n, nil +} + +// DownloadPartial streams up to maxBytes of a document location into w. +func (s *Service) DownloadPartial(ctx context.Context, location *tg.InputDocumentFileLocation, maxBytes int64, w io.Writer) error { + worker := s.AcquireWorker() + defer s.ReleaseWorker(worker) + + pw := &partialWriter{ + w: w, + remain: maxBytes, + } + + downloader := worker.Client.Downloader() + _, err := downloader.Download(worker.API, location).Stream(ctx, pw) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) { + return err + } + return nil +} + +// DownloadPartialByFileID decodes a Pyrogram/TDLib file_id and downloads up to maxBytes. +func (s *Service) DownloadPartialByFileID(ctx context.Context, fileID string, maxBytes int64, w io.Writer) error { + decoded, err := DecodeFileID(fileID) + if err != nil { + return fmt.Errorf("failed to decode file_id: %w", err) + } + + location := &tg.InputDocumentFileLocation{ + ID: decoded.MediaID, + AccessHash: decoded.AccessHash, + FileReference: decoded.FileReference, + } + + return s.DownloadPartial(ctx, location, maxBytes, w) +} diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go new file mode 100644 index 0000000..cdeab4f --- /dev/null +++ b/internal/telegram/client_test.go @@ -0,0 +1,62 @@ +package telegram + +import ( + "sync/atomic" + "testing" +) + +func TestWorkerPoolAcquisition(t *testing.T) { + w1 := &ClientWorker{ + ID: 1001, + Username: "BotOne", + Ready: true, + Workload: 0, + } + w2 := &ClientWorker{ + ID: 1002, + Username: "BotTwo", + Ready: true, + Workload: 0, + } + + svc := &Service{ + primaryWorker: w1, + workers: []*ClientWorker{w1, w2}, + } + + // 1. First acquisition should pick w1 (or w2 with load 0) + acquired1 := svc.AcquireWorker() + if acquired1 == nil { + t.Fatal("expected worker to be acquired") + } + if atomic.LoadInt64(&acquired1.Workload) != 1 { + t.Errorf("expected workload 1, got %d", acquired1.Workload) + } + + // 2. Next acquisition should pick the worker with load 0 (the other one) + acquired2 := svc.AcquireWorker() + if acquired2 == nil { + t.Fatal("expected second worker to be acquired") + } + if acquired2 == acquired1 { + t.Errorf("expected least loaded worker to be picked, but got the same worker") + } + + // 3. Acquire by specific Bot ID + bot2 := svc.AcquireWorkerForBot("1002") + if bot2 == nil || bot2.ID != 1002 { + t.Errorf("expected worker with ID 1002, got %+v", bot2) + } + + // 4. Release worker decrements load + svc.ReleaseWorker(bot2) + if atomic.LoadInt64(&bot2.Workload) != 1 { + t.Errorf("expected workload to be 1 after release, got %d", bot2.Workload) + } + + // Status string + status := svc.StatusString() + if status != "connected (@BotOne, @BotTwo)" { + t.Errorf("expected 'connected (@BotOne, @BotTwo)', got %q", status) + } +} diff --git a/internal/telegram/fileid.go b/internal/telegram/fileid.go index 9e7ba9e..14b1005 100644 --- a/internal/telegram/fileid.go +++ b/internal/telegram/fileid.go @@ -153,3 +153,87 @@ func readTLBytes(r io.Reader) ([]byte, error) { return data, nil } + +// EncodeFileUniqueID encodes a document media_id into Telegram's Bot API/TDLib file_unique_id string. +func EncodeFileUniqueID(mediaID int64) string { + buf := new(bytes.Buffer) + _ = binary.Write(buf, binary.LittleEndian, int32(2)) // FileUniqueType.DOCUMENT = 2 + _ = binary.Write(buf, binary.LittleEndian, mediaID) + rle := rleEncode(buf.Bytes()) + return base64.RawURLEncoding.EncodeToString(rle) +} + +// EncodeFileID creates a Pyrogram/Bot API compatible file_id string for an audio document. +func EncodeFileID(mediaID, accessHash int64, dcID int32, fileReference []byte) string { + buf := new(bytes.Buffer) + fileType := int32(9) // FileType.AUDIO = 9 + if len(fileReference) > 0 { + fileType |= fileReferenceFlag // (1 << 25) + } + + _ = binary.Write(buf, binary.LittleEndian, fileType) + _ = binary.Write(buf, binary.LittleEndian, dcID) + + if len(fileReference) > 0 { + buf.Write(writeTLBytes(fileReference)) + } + + _ = binary.Write(buf, binary.LittleEndian, mediaID) + _ = binary.Write(buf, binary.LittleEndian, accessHash) + + // minor=30, major=4 + buf.WriteByte(30) + buf.WriteByte(4) + + rle := rleEncode(buf.Bytes()) + return base64.RawURLEncoding.EncodeToString(rle) +} + +func rleEncode(src []byte) []byte { + var dst []byte + n := byte(0) + + for _, b := range src { + if b == 0 { + n++ + if n == 255 { + dst = append(dst, 0, n) + n = 0 + } + } else { + if n > 0 { + dst = append(dst, 0, n) + n = 0 + } + dst = append(dst, b) + } + } + if n > 0 { + dst = append(dst, 0, n) + } + return dst +} + +func writeTLBytes(data []byte) []byte { + buf := new(bytes.Buffer) + l := len(data) + if l < 254 { + buf.WriteByte(byte(l)) + buf.Write(data) + pad := (4 - ((l + 1) % 4)) % 4 + for i := 0; i < pad; i++ { + buf.WriteByte(0) + } + } else { + buf.WriteByte(254) + buf.WriteByte(byte(l & 0xff)) + buf.WriteByte(byte((l >> 8) & 0xff)) + buf.WriteByte(byte((l >> 16) & 0xff)) + buf.Write(data) + pad := (4 - (l % 4)) % 4 + for i := 0; i < pad; i++ { + buf.WriteByte(0) + } + } + return buf.Bytes() +} diff --git a/internal/telegram/fileid_test.go b/internal/telegram/fileid_test.go index b9b083b..3b01e0c 100644 --- a/internal/telegram/fileid_test.go +++ b/internal/telegram/fileid_test.go @@ -24,4 +24,24 @@ func TestDecodeFileID(t *testing.T) { if decoded.DCID != 1 { t.Errorf("expected DCID 1, got %d", decoded.DCID) } + + // Test re-encoding back to file_id + reEncoded := EncodeFileID(decoded.MediaID, decoded.AccessHash, decoded.DCID, decoded.FileReference) + if reEncoded != sampleFileID { + t.Errorf("expected re-encoded file_id %q, got %q", sampleFileID, reEncoded) + } +} + +func TestEncodeFileUniqueID(t *testing.T) { + // Alphaville Forever Young media_id + uid1 := EncodeFileUniqueID(6149766536438490758) + if uid1 != "AgADhiYAAhVcWFU" { + t.Errorf("expected 'AgADhiYAAhVcWFU', got %q", uid1) + } + + // Taylor Swift willow media_id + uid2 := EncodeFileUniqueID(4999148345483069633) + if uid2 != "AgADwQgAAu-KYEU" { + t.Errorf("expected 'AgADwQgAAu-KYEU', got %q", uid2) + } } diff --git a/internal/telegram/listener.go b/internal/telegram/listener.go new file mode 100644 index 0000000..c535cfc --- /dev/null +++ b/internal/telegram/listener.go @@ -0,0 +1,364 @@ +package telegram + +import ( + "context" + "fmt" + "math" + "regexp" + "strings" + "time" + + "github.com/gotd/td/tg" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/config" + "streamgo/internal/database" +) + +// AccessFilter checks chat access rights. +type AccessFilter interface { + IsChatAllowed(ctx context.Context, chatID int64) bool +} + +// DedupChecker checks whether a track has already been indexed. +type DedupChecker interface { + IsDuplicate(ctx context.Context, fileUniqueID, title, artist, album string, durationSec float64) bool +} + +// IngestionListener listens for incoming Telegram audio uploads and indexes them. +type IngestionListener struct { + cfg *config.Config + tracksCol *mongo.Collection + accessFilter AccessFilter + dedupChecker DedupChecker + onNewTrack func(trackID string) +} + +// NewIngestionListener creates a new IngestionListener. +func NewIngestionListener( + cfg *config.Config, + db *database.Client, + filter AccessFilter, + dedup DedupChecker, + onNewTrack func(trackID string), +) *IngestionListener { + var tracksCol *mongo.Collection + if db != nil { + tracksCol = db.Collection("audioTracks") + } + return &IngestionListener{ + cfg: cfg, + tracksCol: tracksCol, + accessFilter: filter, + dedupChecker: dedup, + onNewTrack: onNewTrack, + } +} + +// SetupDispatcher configures an update dispatcher to intercept audio uploads. +func (l *IngestionListener) SetupDispatcher(d *tg.UpdateDispatcher) { + d.OnNewChannelMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateNewChannelMessage) error { + if msg, ok := u.Message.(*tg.Message); ok { + l.handleMessage(ctx, msg) + } + return nil + }) + + d.OnNewMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateNewMessage) error { + if msg, ok := u.Message.(*tg.Message); ok { + l.handleMessage(ctx, msg) + } + return nil + }) +} + +func (l *IngestionListener) handleMessage(ctx context.Context, msg *tg.Message) { + if msg == nil || msg.Media == nil { + return + } + + mediaDoc, ok := msg.Media.(*tg.MessageMediaDocument) + if !ok || mediaDoc.Document == nil { + return + } + + doc, ok := mediaDoc.Document.(*tg.Document) + if !ok { + return + } + + mimeType := strings.ToLower(doc.MimeType) + var audioAttr *tg.DocumentAttributeAudio + var fileName string + + for _, attr := range doc.Attributes { + switch a := attr.(type) { + case *tg.DocumentAttributeAudio: + audioAttr = a + case *tg.DocumentAttributeFilename: + fileName = a.FileName + } + } + + isAudio := strings.HasPrefix(mimeType, "audio/") || audioAttr != nil + if !isAudio { + return + } + + // Extract Chat ID + var chatID int64 + switch p := msg.PeerID.(type) { + case *tg.PeerChannel: + chatID = -1000000000000 - p.ChannelID + case *tg.PeerChat: + chatID = -p.ChatID + case *tg.PeerUser: + chatID = p.UserID + } + + // Access filter check + if l.accessFilter != nil && !l.accessFilter.IsChatAllowed(ctx, chatID) { + log.Debugf("Ignored audio from unauthorized chat: %d", chatID) + return + } + + // Metadata extraction + title := "" + artist := "" + duration := 0.0 + + if audioAttr != nil { + title = strings.TrimSpace(audioAttr.Title) + artist = strings.TrimSpace(audioAttr.Performer) + duration = float64(audioAttr.Duration) + } + + if title == "" { + title = fileName + } + if title == "" { + title = fmt.Sprintf("Audio %d", msg.ID) + } + + // Forum topic extraction + var topicID int32 + topicName := "" + if msg.ReplyTo != nil { + if header, ok := msg.ReplyTo.(*tg.MessageReplyHeader); ok { + if header.ReplyToTopID != 0 { + topicID = int32(header.ReplyToTopID) + } else if header.ReplyToMsgID != 0 { + topicID = int32(header.ReplyToMsgID) + } + } + } + if topicID != 0 { + topicName = fmt.Sprintf("topic_%d", topicID) + } + + // Source and Cache Message / Chat IDs (handle forwards properly) + cacheChatID := chatID + cacheMsgID := int32(msg.ID) + + sourceChatID := chatID + sourceMsgID := int32(msg.ID) + + if fwd, ok := msg.GetFwdFrom(); ok { + if fromID, ok := fwd.GetFromID(); ok { + switch p := fromID.(type) { + case *tg.PeerChannel: + sourceChatID = -1000000000000 - p.ChannelID + case *tg.PeerChat: + sourceChatID = -p.ChatID + case *tg.PeerUser: + sourceChatID = p.UserID + } + } + if post, ok := fwd.GetChannelPost(); ok && post != 0 { + sourceMsgID = int32(post) + } else if savedMsg, ok := fwd.GetSavedFromMsgID(); ok && savedMsg != 0 { + sourceMsgID = int32(savedMsg) + } + } + + // Telegram File IDs: file_unique_id is the document _id + fileUniqueID := EncodeFileUniqueID(doc.ID) + primaryFileID := EncodeFileID(doc.ID, doc.AccessHash, int32(doc.DCID), doc.FileReference) + trackID := fileUniqueID + + // Extract audio format + audioExt := "mp3" + lowerName := strings.ToLower(fileName) + if strings.HasSuffix(lowerName, ".flac") || strings.Contains(mimeType, "flac") { + audioExt = "flac" + } else if strings.HasSuffix(lowerName, ".mp3") || strings.Contains(mimeType, "mp3") { + audioExt = "mp3" + } else if strings.HasSuffix(lowerName, ".m4a") || strings.Contains(mimeType, "m4a") { + audioExt = "m4a" + } else if strings.HasSuffix(lowerName, ".ogg") || strings.Contains(mimeType, "ogg") { + audioExt = "ogg" + } else if strings.HasSuffix(lowerName, ".wav") || strings.Contains(mimeType, "wav") { + audioExt = "wav" + } + + album := "" + durSec := int32(math.Round(duration)) + + // Deduplication check + if l.dedupChecker != nil && l.dedupChecker.IsDuplicate(ctx, fileUniqueID, title, artist, album, duration) { + log.Infof("Skipping duplicate audio track: %s - %s", artist, title) + return + } + + // Normalized fingerprint matching Python StreamXBot: title|artist|album|dur + fingerprint := buildFingerprint(title, artist, album, durSec) + now := float64(time.Now().Unix()) + + // Primary Bot ID for telegram.file_ids map + botIDKey := "" + if l.cfg != nil && l.cfg.BotToken != "" { + parts := strings.Split(l.cfg.BotToken, ":") + botIDKey = parts[0] + } + fileIDs := bson.M{} + if botIDKey != "" { + fileIDs[botIDKey] = primaryFileID + } + + var artists []string + if artist != "" { + artists = []string{artist} + } else { + artists = []string{} + } + + normalizedMime := normalizeMimeType(doc.MimeType, fileName) + + // Build Audio object strictly matching original source schema (no titles/file_size/mime/cover/lyrics in audio) + audioDoc := bson.M{ + "title": title, + "artist": artist, + "artists": artists, + "duration_sec": durSec, + "type": audioExt, + } + if album != "" { + audioDoc["album"] = album + aid := slugify(album) + if aid != "" { + audioDoc["album_id"] = "album_" + aid + } + } + + setFields := bson.M{ + "source_chat_id": sourceChatID, + "source_message_id": sourceMsgID, + "cache_chat_id": cacheChatID, + "cache_message_id": cacheMsgID, + "topic_id": topicID, + "topic_name": topicName, + "fingerprint": fingerprint, + "audio": audioDoc, + "telegram": bson.M{ + "file_id": primaryFileID, + "file_unique_id": fileUniqueID, + "file_size": doc.Size, + "mime_type": normalizedMime, + "file_name": fileName, + "file_ids": fileIDs, + }, + "titles": bson.M{ + "original": title, + }, + "enriched": false, + "indexed": true, + "deleted": false, + "updated_at": now, + } + + if l.tracksCol != nil { + opts := options.UpdateOne().SetUpsert(true) + _, err := l.tracksCol.UpdateOne(ctx, bson.M{"_id": trackID}, bson.M{ + "$set": setFields, + "$setOnInsert": bson.M{ + "_id": trackID, + "created_at": now, + }, + }, opts) + if err == nil { + log.Infof("Indexed new Telegram audio track: %s (%s - %s)", trackID, artist, title) + if l.onNewTrack != nil { + l.onNewTrack(trackID) + } + } else { + log.Errorf("Failed to index audio track %s: %v", trackID, err) + } + } +} + +func buildFingerprint(title, artist, album string, durSec int32) string { + t := normalizeListenerText(title) + a := normalizeListenerText(artist) + al := normalizeListenerText(album) + durKey := "" + if durSec > 0 { + durKey = fmt.Sprintf("%d", (durSec/2)*2) + } + return strings.Trim(strings.Join([]string{t, a, al, durKey}, "|"), "|") +} + +func normalizeListenerText(val string) string { + s := strings.ToLower(val) + reNonAlpha := regexp.MustCompile(`[^a-z0-9]+`) + s = reNonAlpha.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + +func slugify(val string) string { + s := normalizeListenerText(val) + return strings.ReplaceAll(s, " ", "_") +} + +func normalizeMimeType(mime string, fileName string) string { + if fileName != "" { + fn := strings.ToLower(strings.TrimSpace(fileName)) + switch { + case strings.HasSuffix(fn, ".wav") || strings.HasSuffix(fn, ".wave"): + return "audio/wav" + case strings.HasSuffix(fn, ".flac"): + return "audio/flac" + case strings.HasSuffix(fn, ".mp3"): + return "audio/mpeg" + case strings.HasSuffix(fn, ".m4a"): + return "audio/mp4" + case strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".opus"): + return "audio/ogg" + case strings.HasSuffix(fn, ".aac"): + return "audio/aac" + } + } + + if mime == "" { + return "audio/mpeg" + } + + raw := strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0])) + switch { + case raw == "audio/flac" || raw == "audio/x-flac" || strings.HasSuffix(raw, "/x-flac"): + return "audio/flac" + case raw == "audio/wav" || raw == "audio/x-wav" || raw == "audio/wave": + return "audio/wav" + case raw == "audio/mp3" || raw == "audio/mpeg": + return "audio/mpeg" + case raw == "audio/m4a" || raw == "audio/x-m4a" || raw == "audio/mp4": + return "audio/mp4" + case raw == "audio/ogg" || raw == "application/ogg": + return "audio/ogg" + case raw == "audio/aac": + return "audio/aac" + default: + return raw + } +} From 67477f4f24b62e852755c4ad02c12e7700860ac1 Mon Sep 17 00:00:00 2001 From: Kousik Date: Sat, 19 Sep 2026 17:38:38 +0530 Subject: [PATCH 08/39] test --- .dockerignore | 8 + Dockerfile | 54 + cmd/server/main.go | 22 +- docker-compose.yml | 16 + go.mod | 89 +- go.sum | 2 + internal/api/handlers/access_control.go | 620 ++++++ internal/api/handlers/albums.go | 21 +- internal/api/handlers/albums_test.go | 269 +++ internal/api/handlers/artists.go | 22 +- internal/api/handlers/auth.go | 879 +++++++- internal/api/handlers/daily_playlists.go | 65 + internal/api/handlers/discord.go | 150 ++ internal/api/handlers/favourites.go | 70 +- internal/api/handlers/history.go | 104 + .../history_daily_access_share_test.go | 436 ++++ internal/api/handlers/playlists.go | 319 ++- .../playlists_favourites_discord_test.go | 412 ++++ internal/api/handlers/share.go | 861 ++++++++ internal/api/handlers/sources.go | 298 +++ internal/api/handlers/sources_test.go | 143 ++ internal/api/handlers/stream.go | 66 +- internal/api/handlers/tracks.go | 3 + internal/api/middleware/auth.go | 92 +- internal/api/response.go | 16 + internal/config/config.go | 145 +- internal/models/access_control.go | 66 + internal/models/artist_album.go | 69 +- internal/models/auth_user.go | 221 ++- internal/models/daily_playlist.go | 30 + internal/models/favourite_playlist.go | 196 +- internal/models/friends.go | 28 + internal/models/history.go | 20 + internal/models/track.go | 54 +- internal/repository/access_control_repo.go | 300 +++ internal/repository/artist_album_repo.go | 580 +++++- internal/repository/daily_playlist_repo.go | 53 + .../repository/favourite_playlist_repo.go | 601 ++++-- internal/repository/history_repo.go | 233 +++ internal/repository/track_repo.go | 99 +- internal/repository/user_repo.go | 279 ++- internal/server/docs.go | 1247 ++++++++++-- internal/server/router.go | 111 +- internal/server/spa.go | 127 ++ internal/services/access_control_service.go | 268 +++ internal/services/access_filter.go | 133 +- internal/services/artist_album_service.go | 117 +- internal/services/auth_service.go | 1763 ++++++++++++++++- internal/services/auth_service_test.go | 478 ++++- internal/services/daily_playlist_service.go | 196 ++ internal/services/dedup_service.go | 4 +- internal/services/discord_service.go | 583 ++++++ internal/services/discord_test.go | 86 + internal/services/enrichment_service.go | 68 +- .../services/favourite_playlist_service.go | 551 ++++-- internal/services/favourite_playlist_test.go | 356 ++++ internal/services/filter_mode_test.go | 118 ++ internal/services/history_service.go | 120 ++ internal/services/internal_services_test.go | 7 + .../services/lyrics_enrichment_service.go | 495 ++++- internal/services/lyrics_enrichment_test.go | 66 + internal/services/mediainfo.go | 2 +- internal/services/stream_service.go | 284 ++- internal/services/track_service.go | 23 +- internal/telegram/client.go | 417 +++- internal/telegram/listener.go | 20 +- scripts/verify_endpoints.go | 165 -- 67 files changed, 14480 insertions(+), 1336 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 internal/api/handlers/access_control.go create mode 100644 internal/api/handlers/albums_test.go create mode 100644 internal/api/handlers/daily_playlists.go create mode 100644 internal/api/handlers/discord.go create mode 100644 internal/api/handlers/history.go create mode 100644 internal/api/handlers/history_daily_access_share_test.go create mode 100644 internal/api/handlers/playlists_favourites_discord_test.go create mode 100644 internal/api/handlers/share.go create mode 100644 internal/api/handlers/sources.go create mode 100644 internal/api/handlers/sources_test.go create mode 100644 internal/models/access_control.go create mode 100644 internal/models/daily_playlist.go create mode 100644 internal/models/friends.go create mode 100644 internal/models/history.go create mode 100644 internal/repository/access_control_repo.go create mode 100644 internal/repository/daily_playlist_repo.go create mode 100644 internal/repository/history_repo.go create mode 100644 internal/server/spa.go create mode 100644 internal/services/access_control_service.go create mode 100644 internal/services/daily_playlist_service.go create mode 100644 internal/services/discord_service.go create mode 100644 internal/services/discord_test.go create mode 100644 internal/services/favourite_playlist_test.go create mode 100644 internal/services/filter_mode_test.go create mode 100644 internal/services/history_service.go create mode 100644 internal/services/lyrics_enrichment_test.go delete mode 100644 scripts/verify_endpoints.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3bf6db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +bin/ +stream_media/ +.env +*.log +cmd/test_* +scratch/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb1b7fe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.4 + +# Stage 1: Build WebX SPA Frontend +FROM node:20-alpine AS frontend-builder + +RUN apk add --no-cache git + +ARG WEBX_REPO=https://github.com/StreamXProject/WebX.git +RUN git clone --depth 1 ${WEBX_REPO} /app/WebX + +WORKDIR /app/WebX + +RUN npm install +RUN npm run build + +# Stage 2: Build StreamGO Backend Binary +FROM golang:1.24-alpine AS go-builder + +RUN apk add --no-cache git ca-certificates + +ENV GOTOOLCHAIN=auto + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/bin/server ./cmd/server + +# Stage 3: Production Runtime +FROM alpine:3.21 + +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + ffmpeg \ + mediainfo + +WORKDIR /app + +# Copy compiled Go server binary +COPY --from=go-builder /app/bin/server /app/server + +# Copy compiled WebX SPA frontend +COPY --from=frontend-builder /app/WebX/dist /app/dist + +# Default configuration environment variables +ENV PORT=8000 \ + APP_ENV=production + +EXPOSE 8000 + +ENTRYPOINT ["/app/server"] diff --git a/cmd/server/main.go b/cmd/server/main.go index a51851d..242b766 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,14 +25,12 @@ func main() { fmt.Println(" StreamGO - High Performance Media API ") fmt.Println("==================================================") - // 1. Load Configuration cfg := config.Load() log.Infof("loaded config: PORT=%s, DB=%s, API_LOGS=%v", cfg.Port, cfg.DatabaseName, cfg.APILogs) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // 2. Initialize MongoDB Connection var dbClient *database.Client if cfg.MongoURI != "" { dbCtx, dbCancel := context.WithTimeout(ctx, 10*time.Second) @@ -51,18 +49,20 @@ func main() { } } - // 3. Initialize Internal Auxiliary Services coverSearch := services.NewCoverSearchService() - lyricsSvc := services.NewLyricsEnrichmentService() + lyricsSvc := services.NewLyricsEnrichmentService(cfg) dedupSvc := services.NewDedupService(dbClient) accessFilter := services.NewAccessFilter(cfg, dbClient) enrichSvc := services.NewEnrichmentService(dbClient, coverSearch, lyricsSvc) if dbClient != nil { - enrichSvc.Start(ctx, 2) + workers := cfg.EnrichmentWorkers + if workers <= 0 { + workers = 2 + } + enrichSvc.Start(ctx, workers) } - // 4. Initialize Telegram Service (Multi-Client MTProto Pool) var tgService *telegram.Service if cfg.ApiID > 0 && cfg.ApiHash != "" { svc, err := telegram.New(cfg) @@ -71,9 +71,8 @@ func main() { } else { tgService = svc enrichSvc.SetDownloader(tgService) - - // Attach Ingestion Listener for live channel/group audio uploads listener := telegram.NewIngestionListener(cfg, dbClient, accessFilter, dedupSvc, enrichSvc.TriggerEnrich) + listener.SetTelegramService(tgService) listener.SetupDispatcher(&tgService.Dispatcher) log.Info("Starting Telegram MTProto multi-client pool in background...") @@ -88,17 +87,15 @@ func main() { log.Info("Telegram credentials (API_ID/API_HASH) not configured; skipping Telegram init.") } - // 4. Initialize HTTP Server with Chi Router - srv := server.New(cfg, dbClient, tgService) + srv := server.New(cfg, dbClient, tgService, accessFilter) httpServer := &http.Server{ Addr: fmt.Sprintf(":%s", cfg.Port), Handler: srv.Router, ReadTimeout: 30 * time.Second, - WriteTimeout: 60 * time.Second, + WriteTimeout: 0, IdleTimeout: 120 * time.Second, } - // 5. Start HTTP Listener in a separate goroutine go func() { log.Infof("HTTP server listening on http://0.0.0.0:%s", cfg.Port) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -106,7 +103,6 @@ func main() { } }() - // 6. Graceful Shutdown on OS Signal (SIGINT, SIGTERM) signal.Ignore(syscall.SIGHUP) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4683ff2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + streamgo: + container_name: streamgo + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + ports: + - "${PORT:-8000}:${PORT:-8000}" + volumes: + - ./stream_media:/app/stream_media + - ./cookies:/app/cookies + # Optional: uncomment to mount host dist for live frontend development without image rebuild + # - ./dist:/app/dist:ro + restart: unless-stopped diff --git a/go.mod b/go.mod index cc65b72..75891f8 100644 --- a/go.mod +++ b/go.mod @@ -1,57 +1,58 @@ module streamgo -go 1.27.0 +go 1.25.0 require ( + github.com/coder/websocket v1.8.15 github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gotd/td v0.162.0 github.com/joho/godotenv v1.5.1 go.mongodb.org/mongo-driver/v2 v2.9.1 + golang.org/x/crypto v0.54.0 + golang.org/x/text v0.40.0 ) require ( - github.com/andybalholm/brotli v1.2.1 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coder/websocket v1.8.15 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-faster/errors v0.8.0 // indirect - github.com/go-faster/jx v1.2.0 // indirect - github.com/go-faster/xor v1.0.0 // indirect - github.com/go-faster/yaml v0.4.6 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gotd/ige v0.3.0 // indirect - github.com/gotd/log v0.1.0 // indirect - github.com/gotd/neo v0.1.5 // indirect - github.com/klauspost/compress v1.19.2 // indirect - github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/ogen-go/ogen v1.23.0 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect - github.com/segmentio/asm v1.2.1 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.2.0 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - github.com/yuin/goldmark v1.8.5 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.28.0 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/tools v0.48.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - rsc.io/qr v0.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 + github.com/cenkalti/backoff/v4 v4.3.0 + github.com/cespare/xxhash/v2 v2.3.0 + github.com/dlclark/regexp2 v1.12.0 + github.com/fatih/color v1.19.0 + github.com/ghodss/yaml v1.0.0 + github.com/go-faster/errors v0.8.0 + github.com/go-faster/jx v1.2.0 + github.com/go-faster/xor v1.0.0 + github.com/go-faster/yaml v0.4.6 + github.com/google/uuid v1.6.0 + github.com/gotd/ige v0.3.0 + github.com/gotd/log v0.1.0 + github.com/gotd/neo v0.1.5 + github.com/klauspost/compress v1.19.2 + github.com/mattn/go-colorable v0.1.15 + github.com/mattn/go-isatty v0.0.22 + github.com/ogen-go/ogen v1.23.0 + github.com/refraction-networking/utls v1.8.2 + github.com/segmentio/asm v1.2.1 + github.com/shopspring/decimal v1.4.0 + github.com/xdg-go/pbkdf2 v1.0.0 + github.com/xdg-go/scram v1.2.0 + github.com/xdg-go/stringprep v1.0.4 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 + github.com/yuin/goldmark v1.8.5 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/atomic v1.11.0 + go.uber.org/multierr v1.11.0 + go.uber.org/zap v1.28.0 + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 + golang.org/x/mod v0.38.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/tools v0.48.0 + gopkg.in/yaml.v2 v2.4.0 + rsc.io/qr v0.2.0 ) diff --git a/go.sum b/go.sum index cae1e77..2a016dd 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/api/handlers/access_control.go b/internal/api/handlers/access_control.go new file mode 100644 index 0000000..abf8e97 --- /dev/null +++ b/internal/api/handlers/access_control.go @@ -0,0 +1,620 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// AccessControlHandler handles administrative access policy, Telegram membership, invites, and user control. +type AccessControlHandler struct { + accessSvc *services.AccessControlService + authSvc *services.AuthService + cfg *config.Config + httpClient *http.Client +} + +// NewAccessControlHandler creates an AccessControlHandler. +func NewAccessControlHandler(accessSvc *services.AccessControlService, authSvc *services.AuthService, cfg *config.Config) *AccessControlHandler { + return &AccessControlHandler{ + accessSvc: accessSvc, + authSvc: authSvc, + cfg: cfg, + httpClient: &http.Client{Timeout: 8 * time.Second}, + } +} + +// Routes mounts /admin/access endpoints. +func (h *AccessControlHandler) Routes(r chi.Router) { + optionalAuth := middleware.OptionalAuth(h.authSvc) + + r.Route("/admin/access", func(sub chi.Router) { + sub.Use(optionalAuth) + + // Policy + sub.Get("/policy", h.GetPolicy) + sub.Patch("/policy", h.PatchPolicy) + + // Required Telegram chats + sub.Post("/required-chats", h.AddRequiredChat) + sub.Delete("/required-chats/{chat_id}", h.RemoveRequiredChat) + sub.Delete("/required-chats", h.RemoveRequiredChat) + + // Invite codes + sub.Get("/invites", h.ListInvites) + sub.Post("/invites", h.CreateInvite) + sub.Delete("/invites/{code}", h.RevokeInvite) + sub.Delete("/invites", h.RevokeInvite) + + // Allowlist + sub.Get("/allowlist", h.ListAllowlist) + sub.Post("/allowlist", h.AddAllowlist) + sub.Delete("/allowlist/{user_id}", h.RemoveAllowlist) + sub.Delete("/allowlist", h.RemoveAllowlist) + + // Bypass + sub.Get("/bypass", h.ListBypass) + sub.Post("/bypass", h.AddBypass) + sub.Delete("/bypass/{user_id}", h.RemoveBypass) + sub.Delete("/bypass", h.RemoveBypass) + + // User accounts + sub.Get("/users", h.ListUsers) + sub.Post("/users/{id}/lock", h.LockUser) + sub.Post("/users/{id}/unlock", h.UnlockUser) + sub.Post("/users/{id}/revoke-sessions", h.RevokeSessions) + + // Reverify Telegram membership + sub.Post("/reverify", h.Reverify) + }) +} + +// checkAdmin verifies that the caller has admin permissions (owner, sudo, or valid guest password). +func (h *AccessControlHandler) checkAdmin(r *http.Request) (int64, error) { + if middleware.IsGuest(r.Context()) { + return 0, nil + } + + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + return 0, fmt.Errorf("authentication required") + } + + if len(h.cfg.OwnerIDs) == 0 && len(h.cfg.SudoUsers) == 0 { + return userID, nil + } + + for _, oid := range h.cfg.OwnerIDs { + if oid == userID { + return userID, nil + } + } + for _, sid := range h.cfg.SudoUsers { + if sid == userID { + return userID, nil + } + } + + return 0, fmt.Errorf("forbidden: administrator privileges required") +} + +// GetPolicy handles GET /admin/access/policy. +func (h *AccessControlHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + policy, err := h.accessSvc.GetPolicy(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "policy": policy, + }) +} + +// PatchPolicy handles PATCH /admin/access/policy. +func (h *AccessControlHandler) PatchPolicy(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var patch models.PolicyPatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid json payload") + return + } + + updated, err := h.accessSvc.UpdatePolicy(r.Context(), patch, adminID) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "policy": updated, + }) +} + +// AddRequiredChat handles POST /admin/access/required-chats. +func (h *AccessControlHandler) AddRequiredChat(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var chat models.RequiredChat + if err := json.NewDecoder(r.Body).Decode(&chat); err != nil || chat.ChatID == 0 { + api.RespondError(w, http.StatusBadRequest, "chat_id is required") + return + } + + policy, err := h.accessSvc.AddRequiredChat(r.Context(), chat) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "required_chats": policy.RequiredChats, + }) +} + +// RemoveRequiredChat handles DELETE /admin/access/required-chats/{chat_id}. +func (h *AccessControlHandler) RemoveRequiredChat(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + chatIDStr := chi.URLParam(r, "chat_id") + if chatIDStr == "" { + chatIDStr = r.URL.Query().Get("chat_id") + } + + chatID, _ := strconv.ParseInt(chatIDStr, 10, 64) + if chatID == 0 { + api.RespondError(w, http.StatusBadRequest, "chat_id is required") + return + } + + policy, err := h.accessSvc.RemoveRequiredChat(r.Context(), chatID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "required_chats": policy.RequiredChats, + }) +} + +// ListInvites handles GET /admin/access/invites. +func (h *AccessControlHandler) ListInvites(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + includeDead := api.ParseQueryBool(r, "include_dead", false) + invites, err := h.accessSvc.ListInvites(r.Context(), includeDead) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "invites": invites, + }) +} + +// CreateInvite handles POST /admin/access/invites. +func (h *AccessControlHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var req models.InviteCreateRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + inv, err := h.accessSvc.CreateInvite(r.Context(), adminID, req.MaxUses, req.TTLDays, req.Note) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "invite": inv, + }) +} + +// RevokeInvite handles DELETE /admin/access/invites/{code}. +func (h *AccessControlHandler) RevokeInvite(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + code := chi.URLParam(r, "code") + if code == "" { + code = r.URL.Query().Get("code") + } + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + api.RespondError(w, http.StatusBadRequest, "invite code is required") + return + } + + revoked := h.accessSvc.RevokeInvite(r.Context(), code) + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "revoked": revoked, + "code": code, + }) +} + +// ListAllowlist handles GET /admin/access/allowlist. +func (h *AccessControlHandler) ListAllowlist(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + list, err := h.accessSvc.ListAllowlist(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "allowlist": list, + }) +} + +// AddAllowlist handles POST /admin/access/allowlist. +func (h *AccessControlHandler) AddAllowlist(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var req models.AllowlistRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + if err := h.accessSvc.AddToAllowlist(r.Context(), req.UserID, adminID, req.Note); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": req.UserID, + "message": "User added to allowlist", + }) +} + +// RemoveAllowlist handles DELETE /admin/access/allowlist/{user_id}. +func (h *AccessControlHandler) RemoveAllowlist(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + uidStr := chi.URLParam(r, "user_id") + if uidStr == "" { + uidStr = r.URL.Query().Get("user_id") + } + uid, _ := strconv.ParseInt(uidStr, 10, 64) + if uid <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + removed := h.accessSvc.RemoveFromAllowlist(r.Context(), uid) + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "removed": removed, + "user_id": uid, + }) +} + +// ListBypass handles GET /admin/access/bypass. +func (h *AccessControlHandler) ListBypass(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + list, err := h.accessSvc.ListBypass(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "bypass": list, + }) +} + +// AddBypass handles POST /admin/access/bypass. +func (h *AccessControlHandler) AddBypass(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var req models.AllowlistRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + if err := h.accessSvc.AddToBypass(r.Context(), req.UserID, adminID, req.Note); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": req.UserID, + "message": "User added to bypass list", + }) +} + +// RemoveBypass handles DELETE /admin/access/bypass/{user_id}. +func (h *AccessControlHandler) RemoveBypass(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + uidStr := chi.URLParam(r, "user_id") + if uidStr == "" { + uidStr = r.URL.Query().Get("user_id") + } + uid, _ := strconv.ParseInt(uidStr, 10, 64) + if uid <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + removed := h.accessSvc.RemoveFromBypass(r.Context(), uid) + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "removed": removed, + "user_id": uid, + }) +} + +// ListUsers handles GET /admin/access/users. +func (h *AccessControlHandler) ListUsers(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + query := r.URL.Query().Get("q") + status := r.URL.Query().Get("status") + limit := api.ParseQueryInt(r, "limit", 50) + skip := api.ParseQueryInt(r, "skip", 0) + + resp, err := h.accessSvc.ListUsers(r.Context(), query, status, limit, skip) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "data": resp, + }) +} + +// LockUser handles POST /admin/access/users/{id}/lock. +func (h *AccessControlHandler) LockUser(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + idStr := chi.URLParam(r, "id") + userID, _ := strconv.ParseInt(idStr, 10, 64) + if userID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user id is required") + return + } + + var req models.LockUserRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Reason == "" { + req.Reason = "Account locked by administrator" + } + + if err := h.accessSvc.LockUser(r.Context(), userID, req.Reason, adminID, req.RevokeSessions); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": userID, + "status": "locked", + "reason": req.Reason, + }) +} + +// UnlockUser handles POST /admin/access/users/{id}/unlock. +func (h *AccessControlHandler) UnlockUser(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + idStr := chi.URLParam(r, "id") + userID, _ := strconv.ParseInt(idStr, 10, 64) + if userID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user id is required") + return + } + + if err := h.accessSvc.UnlockUser(r.Context(), userID); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": userID, + "status": "active", + }) +} + +// RevokeSessions handles POST /admin/access/users/{id}/revoke-sessions. +func (h *AccessControlHandler) RevokeSessions(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + idStr := chi.URLParam(r, "id") + userID, _ := strconv.ParseInt(idStr, 10, 64) + if userID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user id is required") + return + } + + version, err := h.accessSvc.RevokeSessions(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": userID, + "token_version": version, + "message": "All active sessions revoked successfully", + }) +} + +// Reverify handles POST /admin/access/reverify. +// Checks membership status of a given user (or caller) in configured required Telegram chats. +func (h *AccessControlHandler) Reverify(w http.ResponseWriter, r *http.Request) { + var targetUserID int64 + var req struct { + UserID int64 `json:"user_id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.UserID > 0 { + targetUserID = req.UserID + } else if uidStr := r.URL.Query().Get("user_id"); uidStr != "" { + targetUserID, _ = strconv.ParseInt(uidStr, 10, 64) + } else if uid, ok := middleware.GetUserID(r.Context()); ok { + targetUserID = uid + } + + policy, err := h.accessSvc.GetPolicy(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + type ChatStatus struct { + ChatID int64 `json:"chat_id"` + Title string `json:"title,omitempty"` + IsMember bool `json:"is_member"` + Status string `json:"status"` + InviteLink string `json:"invite_link,omitempty"` + } + + results := make([]ChatStatus, 0, len(policy.RequiredChats)) + allPassed := true + + for _, chat := range policy.RequiredChats { + st := ChatStatus{ + ChatID: chat.ChatID, + Title: chat.Title, + InviteLink: chat.InviteLink, + Status: "unknown", + } + + if h.cfg.BotToken != "" && targetUserID > 0 { + url := fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember?chat_id=%d&user_id=%d", + h.cfg.BotToken, chat.ChatID, targetUserID) + resp, err := h.httpClient.Get(url) + if err == nil && resp != nil { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + var tgResp struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if json.Unmarshal(bodyBytes, &tgResp) == nil && tgResp.OK { + st.Status = tgResp.Result.Status + if st.Status == "member" || st.Status == "administrator" || st.Status == "creator" { + st.IsMember = true + } else { + st.IsMember = false + allPassed = false + } + } else { + st.Status = "error_or_not_found" + allPassed = false + } + } else { + st.Status = "check_unreachable" + allPassed = false + } + } else { + // No bot token configured or user ID 0, mark as bypass/mock passed + st.IsMember = true + st.Status = "bypassed_no_bot_token" + } + + results = append(results, st) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": targetUserID, + "verified": allPassed, + "enforced": policy.EnforceMembership, + "chats": results, + "chat_count": len(results), + }) +} diff --git a/internal/api/handlers/albums.go b/internal/api/handlers/albums.go index 215f487..24cf474 100644 --- a/internal/api/handlers/albums.go +++ b/internal/api/handlers/albums.go @@ -30,13 +30,14 @@ func (h *AlbumHandler) Routes(r chi.Router) { // List handles GET /albums with pagination and optional artist filter. func (h *AlbumHandler) List(w http.ResponseWriter, r *http.Request) { page := api.ParseQueryInt(r, "page", 1) - perPage := api.ParseQueryInt(r, "per_page", 20) + perPage := api.ParseQueryInt(r, "per_page", 50) if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { perPage = limit } + refresh := api.ParseQueryBool(r, "refresh", false) artistFilter := strings.TrimSpace(r.URL.Query().Get("artist")) - resp, err := h.svc.ListAlbums(r.Context(), page, perPage, artistFilter) + resp, err := h.svc.ListAlbums(r.Context(), page, perPage, artistFilter, refresh) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return @@ -74,15 +75,23 @@ func (h *AlbumHandler) GetTracks(w http.ResponseWriter, r *http.Request) { return } - tracks, err := h.svc.GetAlbumTracks(r.Context(), id) + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 50) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + tracks, total, err := h.svc.GetAlbumTracksPaginated(r.Context(), id, page, perPage) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } api.RespondJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "total": len(tracks), - "items": tracks, + "ok": true, + "page": page, + "per_page": perPage, + "total": total, + "items": tracks, }) } diff --git a/internal/api/handlers/albums_test.go b/internal/api/handlers/albums_test.go new file mode 100644 index 0000000..89225bd --- /dev/null +++ b/internal/api/handlers/albums_test.go @@ -0,0 +1,269 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/models" + "streamgo/internal/services" +) + +type mockArtistAlbumRepoForTest struct { + albums []*models.Album + artists []*models.Artist + tracks []*models.Track +} + +func (m *mockArtistAlbumRepoForTest) ListArtists(ctx context.Context, page, perPage int, refresh bool) ([]*models.Artist, int64, error) { + return m.artists, int64(len(m.artists)), nil +} + +func (m *mockArtistAlbumRepoForTest) RefreshArtistsCache(ctx context.Context, limitTracks, limitArtists int) (int, error) { + return len(m.artists), nil +} + +func (m *mockArtistAlbumRepoForTest) GetArtistByID(ctx context.Context, id string) (*models.Artist, error) { + for _, a := range m.artists { + if a.ID == id || a.Name == id { + return a, nil + } + } + return nil, nil +} + +func (m *mockArtistAlbumRepoForTest) GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) { + return m.tracks, nil +} + +func (m *mockArtistAlbumRepoForTest) GetArtistTracksPaginated(ctx context.Context, artistName string, page, perPage int) ([]*models.Track, int64, error) { + return m.tracks, int64(len(m.tracks)), nil +} + +func (m *mockArtistAlbumRepoForTest) GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) { + return m.albums, nil +} + +func (m *mockArtistAlbumRepoForTest) ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) ([]*models.Album, int64, error) { + return m.albums, int64(len(m.albums)), nil +} + +func (m *mockArtistAlbumRepoForTest) GetAlbumByID(ctx context.Context, id string) (*models.Album, error) { + for _, a := range m.albums { + if a.ID == id { + return a, nil + } + } + return nil, nil +} + +func (m *mockArtistAlbumRepoForTest) GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) { + return m.tracks, nil +} + +func (m *mockArtistAlbumRepoForTest) GetAlbumTracksPaginated(ctx context.Context, albumID string, page, perPage int) ([]*models.Track, int64, error) { + return m.tracks, int64(len(m.tracks)), nil +} + +func (m *mockArtistAlbumRepoForTest) RefreshAlbumsCache(ctx context.Context, limit int) (int, error) { + return len(m.albums), nil +} + +func TestAlbumsAndArtistsPythonResponseShapes(t *testing.T) { + repo := &mockArtistAlbumRepoForTest{ + albums: []*models.Album{ + { + ID: "album_delirium_deluxe_2015", + Id: "album_delirium_deluxe_2015", + Title: "Delirium (Deluxe)", + Artist: "Ellie Goulding", + TracksCount: 1, + TrackCount: 1, + Year: 2015, + CoverURL: "https://example.com/cover.jpg", + }, + }, + artists: []*models.Artist{ + { + ID: "artist_ellie_goulding", + Id: "artist_ellie_goulding", + Name: "Ellie Goulding", + TracksCount: 1, + TrackCount: 1, + CoverURL: "https://example.com/avatar.jpg", + }, + }, + tracks: []*models.Track{ + { + ID: "AgADkAYAAr1QcEU", + Audio: models.AudioMeta{ + Title: "Love Me Like You Do", + Artist: "Ellie Goulding", + Album: "Delirium (Deluxe)", + AlbumID: "album_delirium_deluxe_2015", + DurationSec: 252, + }, + }, + }, + } + + svc := services.NewArtistAlbumService(repo, nil) + albumHandler := NewAlbumHandler(svc) + artistHandler := NewArtistHandler(svc) + + r := chi.NewRouter() + albumHandler.Routes(r) + artistHandler.Routes(r) + + // 1. Test GET /albums + t.Run("GET /albums response matches Python schema", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/albums?limit=200", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode json: %v", err) + } + + if resp["ok"] != true { + t.Errorf("expected ok=true, got %v", resp["ok"]) + } + if int(resp["per_page"].(float64)) != 200 { + t.Errorf("expected per_page=200, got %v", resp["per_page"]) + } + if int(resp["page"].(float64)) != 1 { + t.Errorf("expected page=1, got %v", resp["page"]) + } + if int(resp["total"].(float64)) != 1 { + t.Errorf("expected total=1, got %v", resp["total"]) + } + + items, ok := resp["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("expected 1 item, got %v", resp["items"]) + } + first := items[0].(map[string]any) + if first["id"] != "album_delirium_deluxe_2015" || first["_id"] != "album_delirium_deluxe_2015" { + t.Errorf("expected id and _id to match, got id=%v, _id=%v", first["id"], first["_id"]) + } + if first["title"] != "Delirium (Deluxe)" { + t.Errorf("expected title='Delirium (Deluxe)', got %v", first["title"]) + } + }) + + // 2. Test GET /albums/{id} + t.Run("GET /albums/{id} matches Python schema", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/albums/album_delirium_deluxe_2015", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode json: %v", err) + } + + if resp["ok"] != true { + t.Errorf("expected ok=true, got %v", resp["ok"]) + } + if resp["album"] == nil { + t.Errorf("expected album object, got nil") + } + if resp["tracks"] == nil { + t.Errorf("expected tracks array, got nil") + } + }) + + // 3. Test GET /albums/{id}/tracks + t.Run("GET /albums/{id}/tracks matches Python schema", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/albums/album_delirium_deluxe_2015/tracks", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode json: %v", err) + } + + if resp["ok"] != true { + t.Errorf("expected ok=true, got %v", resp["ok"]) + } + if int(resp["page"].(float64)) != 1 { + t.Errorf("expected page=1, got %v", resp["page"]) + } + if resp["items"] == nil { + t.Errorf("expected items array, got nil") + } + }) + + // 4. Test GET /artists + t.Run("GET /artists matches Python schema", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/artists?limit=200", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode json: %v", err) + } + + if resp["ok"] != true { + t.Errorf("expected ok=true, got %v", resp["ok"]) + } + if int(resp["per_page"].(float64)) != 200 { + t.Errorf("expected per_page=200, got %v", resp["per_page"]) + } + if resp["items"] == nil { + t.Errorf("expected items array, got nil") + } + }) + + // 5. Test GET /artists/{id} + t.Run("GET /artists/{id} matches Python schema", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/artists/artist_ellie_goulding", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode json: %v", err) + } + + if resp["ok"] != true { + t.Errorf("expected ok=true, got %v", resp["ok"]) + } + if resp["artist"] == nil { + t.Errorf("expected artist object, got nil") + } + if resp["popular_tracks"] == nil { + t.Errorf("expected popular_tracks object, got nil") + } + if resp["releases"] == nil { + t.Errorf("expected releases object, got nil") + } + }) +} diff --git a/internal/api/handlers/artists.go b/internal/api/handlers/artists.go index 45fe5fc..e7a09e7 100644 --- a/internal/api/handlers/artists.go +++ b/internal/api/handlers/artists.go @@ -30,12 +30,13 @@ func (h *ArtistHandler) Routes(r chi.Router) { // List handles GET /artists with pagination. func (h *ArtistHandler) List(w http.ResponseWriter, r *http.Request) { page := api.ParseQueryInt(r, "page", 1) - perPage := api.ParseQueryInt(r, "per_page", 20) + perPage := api.ParseQueryInt(r, "per_page", 50) if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { perPage = limit } + refresh := api.ParseQueryBool(r, "refresh", false) - resp, err := h.svc.ListArtists(r.Context(), page, perPage) + resp, err := h.svc.ListArtists(r.Context(), page, perPage, refresh) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return @@ -73,16 +74,23 @@ func (h *ArtistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { return } - limit := api.ParseQueryInt(r, "limit", 50) - tracks, err := h.svc.GetArtistTracks(r.Context(), id, limit) + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 50) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + tracks, total, err := h.svc.GetArtistTracksPaginated(r.Context(), id, page, perPage) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } api.RespondJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "total": len(tracks), - "items": tracks, + "ok": true, + "page": page, + "per_page": perPage, + "total": total, + "items": tracks, }) } diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 46a33bc..aaad297 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -2,7 +2,10 @@ package handlers import ( "encoding/json" + "fmt" "net/http" + "net/url" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -14,7 +17,7 @@ import ( "streamgo/internal/services" ) -// AuthHandler handles authentication routes for Telegram WebApp and widgets. +// AuthHandler handles authentication routes for Telegram WebApp, widgets, OAuth, and password login/register. type AuthHandler struct { cfg *config.Config authSvc *services.AuthService @@ -30,20 +33,116 @@ func NewAuthHandler(cfg *config.Config, authSvc *services.AuthService) *AuthHand // Routes registers authentication endpoints on Chi router. func (h *AuthHandler) Routes(r chi.Router) { + // Telegram WebApp & Web routes r.Post("/auth/telegram", h.TelegramLogin) - r.Post("/auth/tg/login", h.TelegramLogin) - r.Post("/auth/login", h.TelegramLogin) + r.Post("/auth/tg/login", h.TgLogin) r.Post("/webapp/verify", h.TelegramLogin) + // Password login & Register + r.Post("/auth/login", h.UniversalLogin) + r.Post("/auth/register", h.Register) + r.Post("/auth/create", h.DirectRegister) + r.Post("/auth/validate", h.ValidateOTP) + + // Server / Guest password & Setup endpoints + r.Get("/auth/setup/status", h.SetupStatus) + r.Post("/auth/setup", h.SetupPassword) + r.Post("/auth/password", h.ServerPasswordLogin) + r.Post("/auth/password/change", h.ChangePassword) + + // Cookie & session management + r.Post("/auth/cookie", h.SetCookie) + r.Post("/auth/logout", h.Logout) + + // Telegram Login Widget & Token validation r.Post("/auth/telegram/widget", h.WidgetLogin) + r.Post("/auth/telegram/validate-token", h.ValidateToken) + // Telegram OAuth / OIDC endpoints r.Get("/auth/telegram/config", h.Config) r.Get("/webapp/config", h.Config) + r.Get("/auth/telegram/start", h.TelegramStart) + r.Get("/auth/telegram/callback", h.TelegramCallback) - // Authenticated profile route + // Direct Telegram Bot App Session login + r.Post("/auth/telegram/bot-session", h.CreateBotSession) + r.Get("/auth/telegram/bot-session/status", h.CheckBotSessionStatus) + + // Authenticated profile, credentials, and integrations routes r.Group(func(sub chi.Router) { sub.Use(middleware.RequireAuth(h.authSvc)) sub.Get("/auth/me", h.Me) + sub.Post("/auth/credentials", h.SetCredentials) + sub.Post("/auth/integrations", h.UpdateIntegrations) + sub.Post("/auth/fcm-token", h.UpdateFCMToken) + }) +} + +// UniversalLogin handles POST /auth/login, supporting both password login and WebApp initData. +func (h *AuthHandler) UniversalLogin(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + // 1. If init_data is present, process as Telegram WebApp login + if initData, ok := body["init_data"].(string); ok && strings.TrimSpace(initData) != "" { + req := models.TgLoginRequest{ + InitData: initData, + } + if u, ok := body["username"].(string); ok { + req.Username = u + } + if p, ok := body["password"].(string); ok { + req.Password = p + } + if inv, ok := body["invite_code"].(string); ok { + req.InviteCode = inv + } + + resp, err := h.authSvc.TgLogin(r.Context(), req) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + api.RespondJSON(w, http.StatusOK, resp) + return + } + + // 2. Standard username + password login + username, _ := body["username"].(string) + password, _ := body["password"].(string) + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { + api.RespondError(w, http.StatusBadRequest, "username and password are required") + return + } + + resp, err := h.authSvc.LoginWithPassword(r.Context(), username, password) + if err != nil { + if strings.Contains(err.Error(), "invalid username") { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "token": resp.Token, + "first_name": resp.FirstName, + "username": resp.Username, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, }) } @@ -77,9 +176,227 @@ func (h *AuthHandler) TelegramLogin(w http.ResponseWriter, r *http.Request) { return } + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + api.RespondJSON(w, http.StatusOK, resp) } +// TgLogin handles POST /auth/tg/login. +func (h *AuthHandler) TgLogin(w http.ResponseWriter, r *http.Request) { + var payload models.TgLoginRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request payload") + return + } + + resp, err := h.authSvc.TgLogin(r.Context(), payload) + if err != nil { + if strings.Contains(err.Error(), "username already taken") { + api.RespondError(w, http.StatusConflict, err.Error()) + } else if strings.Contains(err.Error(), "invalid username") { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// Register handles POST /auth/register, supporting both direct account creation and Telegram OTP. +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + var payload models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + h.doRegister(w, r, payload) +} + +// DirectRegister handles POST /auth/create as an explicit route for direct user creation. +func (h *AuthHandler) DirectRegister(w http.ResponseWriter, r *http.Request) { + var payload models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + payload.UserID = 0 + h.doRegister(w, r, payload) +} + +func (h *AuthHandler) doRegister(w http.ResponseWriter, r *http.Request, payload models.RegisterRequest) { + resp, err := h.authSvc.RegisterAccount(r.Context(), payload) + if err != nil { + msg := err.Error() + if strings.Contains(strings.ToLower(msg), "already registered") || strings.Contains(strings.ToLower(msg), "already taken") { + api.RespondError(w, http.StatusConflict, msg) + } else if strings.Contains(msg, "invalid username") || strings.Contains(msg, "invalid user id") || strings.Contains(msg, "invite_") { + api.RespondError(w, http.StatusBadRequest, msg) + } else if strings.Contains(msg, "registration_closed") || strings.Contains(msg, "not_allowlisted") || strings.Contains(msg, "membership_required") { + api.RespondError(w, http.StatusForbidden, msg) + } else { + api.RespondError(w, http.StatusInternalServerError, msg) + } + return + } + + // If direct registration created the account, an auth token is issued immediately + if resp.Token != "" { + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "token": resp.Token, + "username": resp.Username, + "first_name": resp.FirstName, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) + return + } + + // Telegram OTP registration pending + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "message": "OTP sent", + "user_id": resp.UserID, + "username": resp.Username, + "first_name": resp.FirstName, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) +} + + +// ValidateOTP handles POST /auth/validate by verifying OTP code and activating account. +func (h *AuthHandler) ValidateOTP(w http.ResponseWriter, r *http.Request) { + var payload models.ValidateOTPRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + resp, err := h.authSvc.ValidateOTP(r.Context(), payload) + if err != nil { + msg := err.Error() + if strings.Contains(msg, "no pending registration") || strings.Contains(msg, "invite_") { + api.RespondError(w, http.StatusBadRequest, msg) + } else if strings.Contains(msg, "invalid OTP") || strings.Contains(msg, "session_revoked") { + api.RespondError(w, http.StatusUnauthorized, msg) + } else if strings.Contains(msg, "registration_closed") || strings.Contains(msg, "not_allowlisted") || strings.Contains(msg, "membership_required") { + api.RespondError(w, http.StatusForbidden, msg) + } else { + api.RespondError(w, http.StatusInternalServerError, msg) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "username": resp.Username, + "token": resp.Token, + "first_name": resp.FirstName, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) +} + +// SetCredentials handles POST /auth/credentials for updating credentials. +func (h *AuthHandler) SetCredentials(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var payload models.SetCredentialsRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.authSvc.UpdateCredentials(r.Context(), userID, payload.Username, payload.Password); err != nil { + if strings.Contains(err.Error(), "username already taken") { + api.RespondError(w, http.StatusConflict, err.Error()) + } else { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// UpdateIntegrations handles POST /auth/integrations. +func (h *AuthHandler) UpdateIntegrations(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var req models.IntegrationsUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + integrations, err := h.authSvc.UpdateIntegrations(r.Context(), userID, req) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "integrations": integrations, + }) +} + +// SetCookie handles POST /auth/cookie to set the auth_token cookie. +func (h *AuthHandler) SetCookie(w http.ResponseWriter, r *http.Request) { + var payload models.SetCookieRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + token := strings.TrimSpace(payload.Token) + if token == "" { + api.RespondError(w, http.StatusBadRequest, "token is required") + return + } + + uid, err := h.authSvc.VerifyToken(token) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, "invalid auth token") + return + } + + h.authSvc.SetAuthCookie(w, token) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": uid}) +} + +// Logout handles POST /auth/logout. +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + h.authSvc.ClearAuthCookie(w) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // WidgetLogin handles POST /auth/telegram/widget. func (h *AuthHandler) WidgetLogin(w http.ResponseWriter, r *http.Request) { var payload models.TelegramWidgetRequest @@ -94,14 +411,69 @@ func (h *AuthHandler) WidgetLogin(w http.ResponseWriter, r *http.Request) { return } + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + api.RespondJSON(w, http.StatusOK, resp) } +// ValidateToken handles POST /auth/telegram/validate-token. +func (h *AuthHandler) ValidateToken(w http.ResponseWriter, r *http.Request) { + var payload models.TelegramTokenLoginRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + tokenVal := strings.TrimSpace(payload.IDToken) + if tokenVal == "" { + tokenVal = strings.TrimSpace(payload.Token) + } + if tokenVal == "" { + api.RespondError(w, http.StatusBadRequest, "id_token is required") + return + } + + user, token, err := h.authSvc.ValidateOIDCIDToken(r.Context(), tokenVal, payload.InviteCode) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, fmt.Sprintf("invalid telegram ID token: %v", err)) + return + } + + if r.URL.Query().Get("set_cookie") != "false" { + h.authSvc.SetAuthCookie(w, token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": user.ID, + "token": token, + "first_name": user.FirstName, + "username": user.Username, + "profile_url": user.ProfileURL, + "photo_url": user.PhotoURL, + }) +} + // Me handles GET /auth/me. func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + if middleware.IsGuest(r.Context()) { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "guest": true, + "user": nil, + }) + return + } + userID, ok := middleware.GetUserID(r.Context()) - if !ok { - api.RespondError(w, http.StatusUnauthorized, "Unauthorized") + if !ok || userID <= 0 { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "guest": true, + "user": nil, + }) return } @@ -115,22 +487,501 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { return } + role := "user" + for _, oid := range h.cfg.OwnerIDs { + if oid == userID { + role = "owner" + break + } + } + if role == "user" { + for _, sid := range h.cfg.SudoUsers { + if sid == userID { + role = "sudo" + break + } + } + } + + isAdmin := role == "owner" || role == "sudo" + + userMap := map[string]any{ + "_id": user.ID, + "id": user.ID, + "user_id": user.ID, + "userid": user.ID, + "username": user.Username, + "first_name": user.FirstName, + "profile_url": user.ProfileURL, + "photo_url": user.PhotoURL, + "telegram": user.Telegram, + "status": user.Status, + "role": role, + "is_admin": isAdmin, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + "integrations": user.Integrations, + } + api.RespondJSON(w, http.StatusOK, map[string]any{ "ok": true, - "user": user, + "user": userMap, + }) +} + +// SetupStatus handles GET /auth/setup/status. +func (h *AuthHandler) SetupStatus(w http.ResponseWriter, r *http.Request) { + resp, err := h.authSvc.GetSetupStatus(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + api.RespondJSON(w, http.StatusOK, resp) +} + +// SetupPassword handles POST /auth/setup. +func (h *AuthHandler) SetupPassword(w http.ResponseWriter, r *http.Request) { + var payload models.SetupPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + token, err := h.authSvc.SetupOwnerPassword(r.Context(), payload.Password) + if err != nil { + if strings.Contains(err.Error(), "already completed") { + api.RespondError(w, http.StatusForbidden, err.Error()) + } else { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "token": token, + }) +} + +// ServerPasswordLogin handles POST /auth/password. +func (h *AuthHandler) ServerPasswordLogin(w http.ResponseWriter, r *http.Request) { + var payload models.SetupPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + token, err := h.authSvc.LoginWithServerPassword(r.Context(), payload.Password) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "token": token, }) } +// ChangePassword handles POST /auth/password/change. +func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + var payload models.SetupPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.authSvc.ChangeOwnerPassword(r.Context(), payload.Password, userID); err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // Config handles GET /auth/telegram/config and GET /webapp/config. func (h *AuthHandler) Config(w http.ResponseWriter, r *http.Request) { - botID := "" - if parts := strings.Split(h.cfg.BotToken, ":"); len(parts) > 0 { - botID = parts[0] - } + clientID := h.authSvc.GetTelegramClientID() + botUsername := h.authSvc.GetBotUsername(r.Context()) + enabled := clientID != "" || botUsername != "" api.RespondJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "bot_id": botID, - "has_auth": true, + "ok": true, + "enabled": enabled, + "client_id": clientID, + "bot_id": clientID, + "bot_username": botUsername, + "has_auth": true, }) } + +// TelegramStart handles GET /auth/telegram/start. +func (h *AuthHandler) TelegramStart(w http.ResponseWriter, r *http.Request) { + redirect := r.URL.Query().Get("redirect") + if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") { + redirect = "/" + } + + frontendURL := r.URL.Query().Get("frontend_url") + frontendBase := getFrontendBaseURL(r, frontendURL, h.cfg) + callbackURI := getTelegramRedirectURI(r, h.cfg, frontendBase) + inviteCode := strings.TrimSpace(r.URL.Query().Get("invite_code")) + + authURL, err := h.authSvc.StartTelegramOIDC(r.Context(), callbackURI, frontendBase, redirect, inviteCode) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + http.Redirect(w, r, authURL, http.StatusFound) +} + +// TelegramCallback handles GET /auth/telegram/callback. +func (h *AuthHandler) TelegramCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + frontendBase := getFrontendBaseURL(r, "", h.cfg) + + // 1. Provider error + if errVal := q.Get("error"); errVal != "" { + errDesc := q.Get("error_description") + if errDesc == "" { + errDesc = errVal + } + http.Redirect(w, r, fmt.Sprintf("%s/login?error=%s", frontendBase, url.QueryEscape(errVal)), http.StatusFound) + return + } + + // 2. Direct Widget Query Callback (?hash=...&id=...) + if q.Get("hash") != "" && q.Get("id") != "" { + id, err := strconv.ParseInt(q.Get("id"), 10, 64) + if err == nil && id > 0 { + authDate, _ := strconv.ParseInt(q.Get("auth_date"), 10, 64) + widgetReq := models.TelegramWidgetRequest{ + ID: id, + FirstName: q.Get("first_name"), + LastName: q.Get("last_name"), + Username: q.Get("username"), + PhotoURL: q.Get("photo_url"), + AuthDate: authDate, + Hash: q.Get("hash"), + } + resp, err := h.authSvc.AuthenticateWidget(r.Context(), widgetReq) + if err == nil { + h.authSvc.SetAuthCookie(w, resp.Token) + renderSuccessPopup(w, resp.Token, resp.UserID, resp.FirstName, resp.Username, resp.PhotoURL, frontendBase+"/") + return + } + } + http.Redirect(w, r, fmt.Sprintf("%s/login?error=invalid_widget_signature", frontendBase), http.StatusFound) + return + } + + // 3. OIDC code exchange (?code=...&state=...) + code := q.Get("code") + state := q.Get("state") + if code != "" && state != "" { + user, token, destPath, err := h.authSvc.ExchangeOIDCCode(r.Context(), code, state) + if err != nil { + http.Redirect(w, r, fmt.Sprintf("%s/login?error=%s", frontendBase, url.QueryEscape(err.Error())), http.StatusFound) + return + } + + h.authSvc.SetAuthCookie(w, token) + redirectTarget := fmt.Sprintf("%s/login?token=%s&redirect=%s", frontendBase, url.QueryEscape(token), url.QueryEscape(destPath)) + renderSuccessPopup(w, token, user.ID, user.FirstName, user.Username, user.PhotoURL, redirectTarget) + return + } + + // 4. Hash fragment fallback (for clients where Telegram returned fragment #tgAuthResult or #id_token) + renderFragmentFallbackHTML(w) +} + +func renderSuccessPopup(w http.ResponseWriter, token string, userID int64, firstName, username, photoURL, redirectTarget string) { + safeFirstName, _ := json.Marshal(firstName) + safeUsername, _ := json.Marshal(username) + safePhoto, _ := json.Marshal(photoURL) + safeTarget, _ := json.Marshal(redirectTarget) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` +`, token, userID, string(safeFirstName), string(safeUsername), string(safePhoto), string(safeTarget), string(safeTarget)) +} + +func renderFragmentFallbackHTML(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ` + + + + + Verifying Telegram Login... + + + +
+
+

Verifying Telegram login

+

Connecting your account, please wait...

+
+ + +`) +} + +func getFrontendBaseURL(r *http.Request, clientRedirect string, cfg *config.Config) string { + if clientRedirect != "" && (strings.HasPrefix(clientRedirect, "http://") || strings.HasPrefix(clientRedirect, "https://")) { + if u, err := url.Parse(clientRedirect); err == nil && u.Scheme != "" && u.Host != "" { + return fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + + origin := r.Header.Get("Origin") + if origin == "" { + referer := r.Header.Get("Referer") + if referer != "" { + if u, err := url.Parse(referer); err == nil && u.Scheme != "" && u.Host != "" { + origin = fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + } + if origin == "" && cfg.CorsOrigin != "" && cfg.CorsOrigin != "*" { + origin = strings.Split(cfg.CorsOrigin, ",")[0] + } + if origin == "" { + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + origin = fmt.Sprintf("%s://%s", proto, host) + } + return strings.TrimRight(origin, "/") +} + +func getTelegramRedirectURI(r *http.Request, cfg *config.Config, frontendBase string) string { + if cfg.TelegramOIDCRedirectURI != "" { + return cfg.TelegramOIDCRedirectURI + } + if frontendBase != "" && (strings.HasPrefix(frontendBase, "http://") || strings.HasPrefix(frontendBase, "https://")) { + return fmt.Sprintf("%s/auth/telegram/callback", strings.TrimRight(frontendBase, "/")) + } + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return fmt.Sprintf("%s://%s/auth/telegram/callback", proto, host) +} + +// UpdateFCMToken handles POST /auth/fcm-token matching Api/schemas/auth.py. +func (h *AuthHandler) UpdateFCMToken(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req models.FCMTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if strings.TrimSpace(req.FCMToken) == "" { + api.RespondError(w, http.StatusBadRequest, "fcm_token is required") + return + } + + if err := h.authSvc.UpdateFCMToken(r.Context(), userID, req.FCMToken); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// CreateBotSession handles POST /auth/telegram/bot-session matching Api/schemas/auth.py. +func (h *AuthHandler) CreateBotSession(w http.ResponseWriter, r *http.Request) { + var req models.TelegramBotSessionRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + res, err := h.authSvc.CreateBotSession(r.Context(), req.InviteCode) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, res) +} + +// CheckBotSessionStatus handles GET /auth/telegram/bot-session/status matching Api/routers/auth.py. +func (h *AuthHandler) CheckBotSessionStatus(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if sessionID == "" { + api.RespondError(w, http.StatusBadRequest, "session_id is required") + return + } + + res, err := h.authSvc.CheckBotSessionStatus(r.Context(), sessionID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + setCookie := r.URL.Query().Get("set_cookie") != "false" + if setCookie && res["status"] == "confirmed" { + if token, ok := res["token"].(string); ok && token != "" { + h.authSvc.SetAuthCookie(w, token) + } + } + + api.RespondJSON(w, http.StatusOK, res) +} diff --git a/internal/api/handlers/daily_playlists.go b/internal/api/handlers/daily_playlists.go new file mode 100644 index 0000000..3c568ed --- /dev/null +++ b/internal/api/handlers/daily_playlists.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/services" +) + +// DailyPlaylistHandler handles daily mix and dynamic playlist endpoints. +type DailyPlaylistHandler struct { + dailySvc *services.DailyPlaylistService + authSvc *services.AuthService +} + +// NewDailyPlaylistHandler creates a new DailyPlaylistHandler. +func NewDailyPlaylistHandler(dailySvc *services.DailyPlaylistService, authSvc *services.AuthService) *DailyPlaylistHandler { + return &DailyPlaylistHandler{ + dailySvc: dailySvc, + authSvc: authSvc, + } +} + +// Routes mounts the daily playlist routes. +func (h *DailyPlaylistHandler) Routes(r chi.Router) { + optionalAuth := middleware.OptionalAuth(h.authSvc) + + r.With(optionalAuth).Get("/playlists/available", h.Available) + r.With(optionalAuth).Get("/daily-playlist/{key}", h.GetDaily) +} + +// Available handles GET /playlists/available. +func (h *DailyPlaylistHandler) Available(w http.ResponseWriter, r *http.Request) { + resp, err := h.dailySvc.GetAvailablePlaylists(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetDaily handles GET /daily-playlist/{key}. +func (h *DailyPlaylistHandler) GetDaily(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + if strings.TrimSpace(key) == "" { + api.RespondError(w, http.StatusBadRequest, "key is required") + return + } + + limit := api.ParseQueryInt(r, "limit", 75) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + userID, _ := middleware.GetUserID(r.Context()) + + resp, err := h.dailySvc.GetDailyPlaylist(r.Context(), key, limit, channelID, userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/discord.go b/internal/api/handlers/discord.go new file mode 100644 index 0000000..3a490dd --- /dev/null +++ b/internal/api/handlers/discord.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/logger" + "streamgo/internal/models" + "streamgo/internal/services" +) + +var logDiscordHandler = logger.New("discord_handler") + +// DiscordHandler handles Discord Remote Auth and external assets endpoints. +type DiscordHandler struct { + discordSvc *services.DiscordService +} + +// NewDiscordHandler creates a new DiscordHandler instance. +func NewDiscordHandler(discordSvc *services.DiscordService) *DiscordHandler { + return &DiscordHandler{ + discordSvc: discordSvc, + } +} + +// Routes mounts Discord Remote Auth and asset proxy endpoints. +func (h *DiscordHandler) Routes(r chi.Router) { + // WebSocket endpoint for real-time QR code flow + r.HandleFunc("/ws/discord/remote-auth", h.WebSocketRemoteAuth) + + // REST API endpoints + r.Post("/api/discord/remote-auth/start", h.StartRemoteAuth) + r.Get("/api/discord/remote-auth/status/{session_id}", h.GetRemoteAuthStatus) + r.Post("/api/discord/remote-auth/cancel/{session_id}", h.CancelRemoteAuth) + r.Post("/api/discord/external-assets", h.ResolveExternalAssets) +} + +// WebSocketRemoteAuth streams Discord QR remote-auth events directly over WebSocket. +func (h *DiscordHandler) WebSocketRemoteAuth(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + logDiscordHandler.Warnf("Failed to accept websocket: %v", err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "done") + + sess, err := h.discordSvc.CreateWSSession(r.Context()) + if err != nil { + logDiscordHandler.Errorf("Failed to create Discord remote auth session: %v", err) + _ = conn.Close(websocket.StatusInternalError, err.Error()) + return + } + defer func() { + sess.Cancel() + _ = h.discordSvc.CancelSession(sess.SessionID) + }() + + logDiscordHandler.Infof("[DiscordRA WS] New connection established: %s", sess.SessionID) + + for { + select { + case <-r.Context().Done(): + return + case ev, ok := <-sess.EventsChan: + if !ok { + return + } + data, err := json.Marshal(ev) + if err != nil { + continue + } + if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil { + logDiscordHandler.Infof("[DiscordRA WS] Client disconnected early: %s", sess.SessionID) + return + } + if typ, _ := ev["type"].(string); typ == "success" || typ == "error" || typ == "cancelled" { + return + } + } + } +} + +// StartRemoteAuth initializes a Discord Remote Auth session and waits up to 4s for initial QR code. +func (h *DiscordHandler) StartRemoteAuth(w http.ResponseWriter, r *http.Request) { + resp, err := h.discordSvc.StartSession(r.Context()) + if err != nil { + api.RespondError(w, http.StatusBadGateway, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetRemoteAuthStatus checks the status of a running Remote Auth session. +func (h *DiscordHandler) GetRemoteAuthStatus(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(chi.URLParam(r, "session_id")) + if sessionID == "" { + api.RespondError(w, http.StatusBadRequest, "session_id is required") + return + } + + resp, err := h.discordSvc.GetStatus(sessionID) + if err != nil { + api.RespondError(w, http.StatusNotFound, "Session not found or expired") + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// CancelRemoteAuth cancels an active Remote Auth session. +func (h *DiscordHandler) CancelRemoteAuth(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(chi.URLParam(r, "session_id")) + if sessionID == "" { + api.RespondError(w, http.StatusBadRequest, "session_id is required") + return + } + + _ = h.discordSvc.CancelSession(sessionID) + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "status": "cancelled", + "session_id": sessionID, + }) +} + +// ResolveExternalAssets proxies external asset resolution to Discord's API with user's token. +func (h *DiscordHandler) ResolveExternalAssets(w http.ResponseWriter, r *http.Request) { + var req models.ExternalAssetsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.RespondJSON(w, http.StatusOK, []any{}) + return + } + + results, err := h.discordSvc.ResolveExternalAssets(r.Context(), req) + if err != nil { + logDiscordHandler.Warnf("ResolveExternalAssets error: %v", err) + api.RespondJSON(w, http.StatusOK, []any{}) + return + } + + api.RespondJSON(w, http.StatusOK, results) +} diff --git a/internal/api/handlers/favourites.go b/internal/api/handlers/favourites.go index ccf22cf..7ed8b3c 100644 --- a/internal/api/handlers/favourites.go +++ b/internal/api/handlers/favourites.go @@ -43,16 +43,18 @@ func (h *FavouriteHandler) Routes(r chi.Router) { sub.Get("/me/favourites", h.ListFavourites) sub.Get("/history", h.GetHistory) sub.Get("/me/history", h.GetHistory) - sub.Get("/top-played", h.GetHistory) - sub.Get("/me/top-played", h.GetHistory) + sub.Get("/top-played", h.GetTopPlayed) + sub.Get("/me/top-played", h.GetTopPlayed) // Artist follows sub.Post("/artists/favourites", h.AddArtistFavourite) sub.Post("/me/artists/favourites", h.AddArtistFavourite) sub.Post("/me/artists/favorite", h.AddArtistFavourite) + sub.Post("/me/artist/favorite", h.AddArtistFavourite) sub.Delete("/artists/favourites/{id}", h.RemoveArtistFavourite) sub.Delete("/me/artists/favourites/{id}", h.RemoveArtistFavourite) sub.Delete("/me/artists/favorite/{id}", h.RemoveArtistFavourite) + sub.Delete("/me/artist/favorite/{id}", h.RemoveArtistFavourite) }) r.Group(func(sub chi.Router) { @@ -130,9 +132,12 @@ func (h *FavouriteHandler) RemoveFavourite(w http.ResponseWriter, r *http.Reques func (h *FavouriteHandler) ListFavouriteIDs(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserID(r.Context()) if !ok { - api.RespondJSON(w, http.StatusOK, models.FavouriteIDsResponse{ - OK: true, - IDs: []string{}, + api.RespondJSON(w, http.StatusOK, models.FavouriteIdsResponse{ + Page: 1, + PerPage: 200, + Total: 0, + IDs: []string{}, + Exists: false, }) return } @@ -169,7 +174,7 @@ func (h *FavouriteHandler) ListFavourites(w http.ResponseWriter, r *http.Request api.RespondJSON(w, http.StatusOK, resp) } -// GetHistory handles GET /history and GET /top-played. +// GetHistory handles GET /history and GET /me/history. func (h *FavouriteHandler) GetHistory(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserID(r.Context()) if !ok { @@ -177,14 +182,43 @@ func (h *FavouriteHandler) GetHistory(w http.ResponseWriter, r *http.Request) { return } + limit := api.ParseQueryInt(r, "limit", 100) + items, err := h.favSvc.GetUserHistory(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }) +} + +// GetTopPlayed handles GET /top-played and GET /me/top-played. +func (h *FavouriteHandler) GetTopPlayed(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + page := api.ParseQueryInt(r, "page", 1) limit := api.ParseQueryInt(r, "limit", 50) - resp, err := h.favSvc.GetUserHistory(r.Context(), userID, limit) + items, total, err := h.favSvc.GetUserTopPlayed(r.Context(), userID, page, limit) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } - api.RespondJSON(w, http.StatusOK, resp) + api.RespondJSON(w, http.StatusOK, &models.BrowseResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + }) } // AddArtistFavourite handles POST /artists/favourites. @@ -267,18 +301,25 @@ func (h *FavouriteHandler) ListFavouriteArtistIDs(w http.ResponseWriter, r *http return } - resp, err := h.favSvc.GetFavouriteArtistIDs(r.Context(), userID) + ids, err := h.favSvc.GetFavouriteArtistIDs(r.Context(), userID) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } - api.RespondJSON(w, http.StatusOK, resp) + api.RespondJSON(w, http.StatusOK, models.FavouriteArtistsResponse{ + OK: true, + IDs: ids, + }) } // RecordListeningEvents handles POST /listening-events. func (h *FavouriteHandler) RecordListeningEvents(w http.ResponseWriter, r *http.Request) { - userID, _ := middleware.GetUserID(r.Context()) + userID, hasUser := middleware.GetUserID(r.Context()) + var uIDPtr *int64 + if hasUser { + uIDPtr = &userID + } var payload models.ListeningEventsPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { @@ -288,12 +329,7 @@ func (h *FavouriteHandler) RecordListeningEvents(w http.ResponseWriter, r *http. count := len(payload.Events) if count > 0 { - _ = h.favSvc.RecordListeningEvents(r.Context(), userID, payload.Events) - for _, ev := range payload.Events { - if userID > 0 && ev.TrackID != "" { - _ = h.favSvc.RecordHistory(r.Context(), userID, ev.TrackID, ev.PlayedAt) - } - } + count, _ = h.favSvc.RecordListeningEvents(r.Context(), uIDPtr, payload.Events) } api.RespondJSON(w, http.StatusOK, map[string]any{ diff --git a/internal/api/handlers/history.go b/internal/api/handlers/history.go new file mode 100644 index 0000000..e646e3a --- /dev/null +++ b/internal/api/handlers/history.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// HistoryHandler handles playback history, top tracks, and listening telemetry. +type HistoryHandler struct { + histSvc *services.HistoryService + authSvc *services.AuthService +} + +// NewHistoryHandler creates a new HistoryHandler. +func NewHistoryHandler(histSvc *services.HistoryService, authSvc *services.AuthService) *HistoryHandler { + return &HistoryHandler{ + histSvc: histSvc, + authSvc: authSvc, + } +} + +// Routes mounts history and playback telemetry endpoints. +func (h *HistoryHandler) Routes(r chi.Router) { + optionalAuth := middleware.OptionalAuth(h.authSvc) + requireAuth := middleware.RequireAuth(h.authSvc) + + // History endpoints + r.With(requireAuth).Get("/history", h.GetHistory) + r.With(requireAuth).Get("/me/history", h.GetHistory) + r.With(requireAuth).Get("/me/top-played", h.GetTopPlayed) + + // Telemetry / listening events + r.With(optionalAuth).Post("/me/listening-events", h.RecordEvents) + r.With(optionalAuth).Post("/listening-events", h.RecordEvents) +} + +// GetHistory handles GET /history and GET /me/history. +func (h *HistoryHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + limit := api.ParseQueryInt(r, "limit", 50) + resp, err := h.histSvc.GetHistory(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetTopPlayed handles GET /me/top-played. +func (h *HistoryHandler) GetTopPlayed(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + limit := api.ParseQueryInt(r, "limit", 50) + resp, err := h.histSvc.GetTopPlayed(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// RecordEvents handles POST /me/listening-events and POST /listening-events. +func (h *HistoryHandler) RecordEvents(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(payload.Events) == 0 { + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "count": 0}) + return + } + + if err := h.histSvc.RecordEvents(r.Context(), userID, payload.Events); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(payload.Events), + }) +} diff --git a/internal/api/handlers/history_daily_access_share_test.go b/internal/api/handlers/history_daily_access_share_test.go new file mode 100644 index 0000000..86e52be --- /dev/null +++ b/internal/api/handlers/history_daily_access_share_test.go @@ -0,0 +1,436 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// Mock History Repo +type mockHistRepo struct { + events []models.ListeningEventItem + history []string + topPlays []string +} + +func (m *mockHistRepo) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { + m.history = append([]string{trackID}, m.history...) + return nil +} + +func (m *mockHistRepo) GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) { + return m.history, nil +} + +func (m *mockHistRepo) GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) { + return m.topPlays, nil +} + +func (m *mockHistRepo) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + m.events = append(m.events, events...) + for _, e := range events { + m.history = append([]string{e.TrackID}, m.history...) + } + return nil +} + +// Mock Daily Playlist Repo +type mockDailyRepo struct { + cache map[string]*models.DailyPlaylistDoc +} + +func (m *mockDailyRepo) GetCachedDailyPlaylist(ctx context.Context, docID string) (*models.DailyPlaylistDoc, error) { + return m.cache[docID], nil +} + +func (m *mockDailyRepo) SaveDailyPlaylist(ctx context.Context, doc *models.DailyPlaylistDoc) error { + if m.cache == nil { + m.cache = make(map[string]*models.DailyPlaylistDoc) + } + m.cache[doc.ID] = doc + return nil +} + +// Mock Access Control Repo +type mockAccessRepo struct { + policy *models.AccessPolicy + invites map[string]*models.InviteCode + allowlist map[int64]*models.AllowlistEntry + bypass map[int64]*models.AllowlistEntry + users map[int64]*models.User +} + +func newMockAccessRepo() *mockAccessRepo { + return &mockAccessRepo{ + policy: &models.AccessPolicy{ + RegistrationMode: "open", + EnforceMembership: false, + RequiredChats: []models.RequiredChat{}, + }, + invites: make(map[string]*models.InviteCode), + allowlist: make(map[int64]*models.AllowlistEntry), + bypass: make(map[int64]*models.AllowlistEntry), + users: make(map[int64]*models.User), + } +} + +func (m *mockAccessRepo) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { + return m.policy, nil +} + +func (m *mockAccessRepo) SavePolicy(ctx context.Context, policy *models.AccessPolicy) error { + m.policy = policy + return nil +} + +func (m *mockAccessRepo) CreateInvite(ctx context.Context, invite *models.InviteCode) error { + m.invites[invite.Code] = invite + return nil +} + +func (m *mockAccessRepo) GetInvite(ctx context.Context, code string) (*models.InviteCode, error) { + return m.invites[code], nil +} + +func (m *mockAccessRepo) ConsumeInvite(ctx context.Context, code string) error { + if inv := m.invites[code]; inv != nil { + inv.UsedCount++ + } + return nil +} + +func (m *mockAccessRepo) RevokeInvite(ctx context.Context, code string) bool { + if inv := m.invites[code]; inv != nil { + inv.Revoked = true + return true + } + return false +} + +func (m *mockAccessRepo) ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) { + var list []*models.InviteCode + for _, inv := range m.invites { + if includeDead || (!inv.Revoked) { + list = append(list, inv) + } + } + return list, nil +} + +func (m *mockAccessRepo) AddToAllowlist(ctx context.Context, entry *models.AllowlistEntry) error { + m.allowlist[entry.UserID] = entry + return nil +} + +func (m *mockAccessRepo) RemoveFromAllowlist(ctx context.Context, userID int64) bool { + if _, ok := m.allowlist[userID]; ok { + delete(m.allowlist, userID) + return true + } + return false +} + +func (m *mockAccessRepo) IsInAllowlist(ctx context.Context, userID int64) bool { + _, ok := m.allowlist[userID] + return ok +} + +func (m *mockAccessRepo) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { + var list []*models.AllowlistEntry + for _, e := range m.allowlist { + list = append(list, e) + } + return list, nil +} + +func (m *mockAccessRepo) AddToBypass(ctx context.Context, entry *models.AllowlistEntry) error { + m.bypass[entry.UserID] = entry + return nil +} + +func (m *mockAccessRepo) RemoveFromBypass(ctx context.Context, userID int64) bool { + if _, ok := m.bypass[userID]; ok { + delete(m.bypass, userID) + return true + } + return false +} + +func (m *mockAccessRepo) IsInBypass(ctx context.Context, userID int64) bool { + _, ok := m.bypass[userID] + return ok +} + +func (m *mockAccessRepo) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { + var list []*models.AllowlistEntry + for _, e := range m.bypass { + list = append(list, e) + } + return list, nil +} + +func (m *mockAccessRepo) ListUsers(ctx context.Context, query string, status string, limit int, skip int) ([]*models.User, int64, error) { + var list []*models.User + for _, u := range m.users { + list = append(list, u) + } + return list, int64(len(list)), nil +} + +func (m *mockAccessRepo) SetUserStatus(ctx context.Context, userID int64, status string, reason string) error { + if u := m.users[userID]; u != nil { + u.Status = status + } + return nil +} + +func (m *mockAccessRepo) RevokeUserSessions(ctx context.Context, userID int64) (int, error) { + if u := m.users[userID]; u != nil { + u.TokenVersion++ + return u.TokenVersion, nil + } + return 1, nil +} + +func TestHistoryHandler(t *testing.T) { + cfg := &config.Config{GuestPassword: "testguestpassword"} + userRepo := &mockUserRepoForHandler{} + authSvc := services.NewAuthService(cfg, userRepo) + + trackRepo := &mockTrackRepoForHandler{} + histRepo := &mockHistRepo{ + topPlays: []string{"trk_1"}, + } + histSvc := services.NewHistoryService(histRepo, trackRepo) + handler := NewHistoryHandler(histSvc, authSvc) + + r := chi.NewRouter() + handler.Routes(r) + + // Create test user and token + user := &models.User{ID: 100, Username: "testuser"} + token, _ := authSvc.CreateToken(user) + + // 1. Post listening event + eventPayload := map[string]any{ + "events": []map[string]any{ + { + "id": "ev_1", + "track_id": "trk_1", + "played_at": float64(time.Now().Unix()), + }, + }, + } + b, _ := json.Marshal(eventPayload) + req := httptest.NewRequest("POST", "/me/listening-events", bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for listening events, got %d: %s", rec.Code, rec.Body.String()) + } + + // 2. Get history + req = httptest.NewRequest("GET", "/history", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for /history, got %d", rec.Code) + } + + // 3. Get top played + req = httptest.NewRequest("GET", "/me/top-played", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for /me/top-played, got %d", rec.Code) + } +} + +func TestDailyPlaylistHandler(t *testing.T) { + cfg := &config.Config{GuestPassword: "testguestpassword"} + userRepo := &mockUserRepoForHandler{} + authSvc := services.NewAuthService(cfg, userRepo) + + trackRepo := &mockTrackRepoForHandler{} + dailyRepo := &mockDailyRepo{cache: make(map[string]*models.DailyPlaylistDoc)} + dailySvc := services.NewDailyPlaylistService(dailyRepo, trackRepo) + handler := NewDailyPlaylistHandler(dailySvc, authSvc) + + r := chi.NewRouter() + handler.Routes(r) + + // 1. Get available playlists + req := httptest.NewRequest("GET", "/playlists/available", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for /playlists/available, got %d", rec.Code) + } + + // 2. Get daily playlist + req = httptest.NewRequest("GET", "/daily-playlist/trending", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for /daily-playlist/trending, got %d", rec.Code) + } +} + +func TestAccessControlHandler(t *testing.T) { + cfg := &config.Config{ + GuestPassword: "admin_guest_pwd", + OwnerIDs: []int64{999}, + } + userRepo := &mockUserRepoForHandler{} + authSvc := services.NewAuthService(cfg, userRepo) + accessRepo := newMockAccessRepo() + accessSvc := services.NewAccessControlService(accessRepo) + + accessRepo.users[555] = &models.User{ID: 555, Username: "targetuser", Status: "active"} + + handler := NewAccessControlHandler(accessSvc, authSvc, cfg) + r := chi.NewRouter() + handler.Routes(r) + + // Admin token + adminUser := &models.User{ID: 999, Username: "admin"} + adminToken, _ := authSvc.CreateToken(adminUser) + + // 1. Get policy + req := httptest.NewRequest("GET", "/admin/access/policy", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for GET /admin/access/policy, got %d", rec.Code) + } + + // 2. Patch policy to invite + patchBody := `{"registration_mode": "invite"}` + req = httptest.NewRequest("PATCH", "/admin/access/policy", bytes.NewBufferString(patchBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for PATCH /admin/access/policy, got %d", rec.Code) + } + + // 3. Create invite + invBody := `{"max_uses": 5, "ttl_days": 10, "note": "VIP invite"}` + req = httptest.NewRequest("POST", "/admin/access/invites", bytes.NewBufferString(invBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for POST /admin/access/invites, got %d", rec.Code) + } + + var invResp struct { + OK bool `json:"ok"` + Invite models.InviteCode `json:"invite"` + } + _ = json.NewDecoder(rec.Body).Decode(&invResp) + if invResp.Invite.Code == "" { + t.Fatalf("expected generated invite code, got empty") + } + + // 4. Lock user + lockBody := `{"reason": "Rule violation", "revoke_sessions": true}` + req = httptest.NewRequest("POST", "/admin/access/users/555/lock", bytes.NewBufferString(lockBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for locking user, got %d", rec.Code) + } + if accessRepo.users[555].Status != "locked" { + t.Fatalf("expected user status locked, got %s", accessRepo.users[555].Status) + } + + // 5. Reverify + req = httptest.NewRequest("POST", "/admin/access/reverify", bytes.NewBufferString(`{"user_id": 555}`)) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for /admin/access/reverify, got %d", rec.Code) + } +} + +func TestShareHandler(t *testing.T) { + cfg := &config.Config{} + trackRepo := &mockTrackRepoForHandler{} + favRepo := &mockFavRepoForHandler{ + playlists: map[string]*models.UserPlaylist{ + "pl_share": { + ID: "pl_share", + Name: "Chill Vibes", + }, + }, + tracks: map[string][]string{ + "pl_share": {"trk_share"}, + }, + } + + handler := NewShareHandler(trackRepo, favRepo, cfg) + r := chi.NewRouter() + handler.Routes(r) + + // 1. Share track HTML preview + req := httptest.NewRequest("GET", "/share/trk_share", nil) + req.Header.Set("Accept", "text/html") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for HTML track share, got %d", rec.Code) + } + body := rec.Body.String() + if !bytes.Contains([]byte(body), []byte("og:title")) { + t.Fatalf("expected OpenGraph meta tags in HTML preview") + } + + // 2. Share track JSON metadata + req = httptest.NewRequest("GET", "/share/trk_share?format=json", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for JSON track share, got %d", rec.Code) + } + + // 3. Share playlist HTML preview + req = httptest.NewRequest("GET", "/share/playlists/pl_share", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for playlist share, got %d", rec.Code) + } +} diff --git a/internal/api/handlers/playlists.go b/internal/api/handlers/playlists.go index d402176..4a60c20 100644 --- a/internal/api/handlers/playlists.go +++ b/internal/api/handlers/playlists.go @@ -9,10 +9,11 @@ import ( "streamgo/internal/api" "streamgo/internal/api/middleware" + "streamgo/internal/models" "streamgo/internal/services" ) -// PlaylistHandler handles custom user playlist endpoints. +// PlaylistHandler handles custom user playlist and album endpoints. type PlaylistHandler struct { favSvc *services.FavouritePlaylistService authSvc *services.AuthService @@ -26,7 +27,7 @@ func NewPlaylistHandler(favSvc *services.FavouritePlaylistService, authSvc *serv } } -// Routes registers all playlist routes on the Chi router. +// Routes registers all playlist and album routes on Chi router. func (h *PlaylistHandler) Routes(r chi.Router) { requireAuth := middleware.RequireAuth(h.authSvc) optAuth := middleware.OptionalAuth(h.authSvc) @@ -34,40 +35,55 @@ func (h *PlaylistHandler) Routes(r chi.Router) { // Protected routes (creation, modification, deletion) r.Group(func(sub chi.Router) { sub.Use(requireAuth) + + // Playlists sub.Post("/playlists", h.Create) sub.Post("/me/playlists", h.Create) - sub.Get("/playlists", h.ListMine) - sub.Get("/me/playlists", h.ListMine) - sub.Patch("/playlists/{id}", h.Update) - sub.Patch("/me/playlists/{id}", h.Update) - sub.Put("/playlists/{id}", h.Update) - sub.Put("/me/playlists/{id}", h.Update) + sub.Patch("/playlists/{id}", h.Rename) + sub.Patch("/me/playlists/{id}", h.Rename) + sub.Patch("/me/playlists/{playlist_id}", h.Rename) + sub.Put("/playlists/{id}", h.Rename) + sub.Put("/me/playlists/{id}", h.Rename) sub.Delete("/playlists/{id}", h.Delete) sub.Delete("/me/playlists/{id}", h.Delete) + sub.Delete("/me/playlists/{playlist_id}", h.Delete) sub.Post("/playlists/{id}/tracks", h.AddTracks) sub.Post("/me/playlists/{id}/tracks", h.AddTracks) + sub.Post("/me/playlists/{playlist_id}/tracks", h.AddTracks) sub.Delete("/playlists/{id}/tracks/{track_id}", h.RemoveTrack) sub.Delete("/me/playlists/{id}/tracks/{track_id}", h.RemoveTrack) + sub.Delete("/me/playlists/{playlist_id}/tracks/{track_id}", h.RemoveTrack) - sub.Post("/playlists/{id}/reorder", h.Reorder) - sub.Post("/me/playlists/{id}/reorder", h.Reorder) + // Tracks listing for playlist (requires auth in StreamXBot) + sub.Get("/me/playlists/{playlist_id}/tracks", h.GetTracks) + sub.Get("/me/playlists/{id}/tracks", h.GetTracks) + sub.Get("/playlists/{id}/tracks", h.GetTracks) + + // Saved Albums + sub.Post("/albums", h.SaveAlbum) + sub.Post("/me/albums", h.SaveAlbum) + sub.Get("/me/albums", h.ListAlbums) }) - // Public / Optional-auth routes (viewing playlist tracks) + // Optional / Public routes r.Group(func(sub chi.Router) { sub.Use(optAuth) - sub.Get("/playlists/{id}", h.GetByID) - sub.Get("/me/playlists/{id}", h.GetByID) - sub.Get("/playlists/{id}/tracks", h.GetTracks) - sub.Get("/me/playlists/{id}/tracks", h.GetTracks) + sub.Get("/playlists", h.ListMine) + sub.Get("/me/playlists", h.ListMine) + + // Shared & public playlist access + sub.Get("/share/playlists/{id}", h.GetSharedPlaylist) + sub.Get("/share/playlists/{playlist_id}", h.GetSharedPlaylist) + sub.Get("/playlists/{id}", h.GetSharedPlaylist) + sub.Get("/me/playlists/{id}", h.GetSharedPlaylist) }) } -// Create handles POST /playlists. +// Create handles POST /playlists and POST /me/playlists. func (h *PlaylistHandler) Create(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserID(r.Context()) if !ok { @@ -76,16 +92,24 @@ func (h *PlaylistHandler) Create(w http.ResponseWriter, r *http.Request) { } var payload struct { - Title string `json:"title"` - Description string `json:"description"` - CoverURL string `json:"cover_url"` + Name string `json:"name"` + Title string `json:"title"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { api.RespondError(w, http.StatusBadRequest, "invalid request body") return } - p, err := h.favSvc.CreatePlaylist(r.Context(), userID, payload.Title, payload.Description, payload.CoverURL) + name := strings.TrimSpace(payload.Name) + if name == "" { + name = strings.TrimSpace(payload.Title) + } + if name == "" { + api.RespondError(w, http.StatusBadRequest, "name is required") + return + } + + p, err := h.favSvc.CreatePlaylist(r.Context(), userID, name) if err != nil { api.RespondError(w, http.StatusBadRequest, err.Error()) return @@ -98,7 +122,7 @@ func (h *PlaylistHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *PlaylistHandler) ListMine(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserID(r.Context()) if !ok { - api.RespondError(w, http.StatusUnauthorized, "Authentication required") + api.RespondJSON(w, http.StatusOK, models.PlaylistsResponse{Items: []models.PlaylistItem{}}) return } @@ -111,29 +135,8 @@ func (h *PlaylistHandler) ListMine(w http.ResponseWriter, r *http.Request) { api.RespondJSON(w, http.StatusOK, resp) } -// GetByID handles GET /playlists/{id}. -func (h *PlaylistHandler) GetByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimSpace(chi.URLParam(r, "id")) - if id == "" { - api.RespondError(w, http.StatusBadRequest, "playlist id is required") - return - } - - detail, err := h.favSvc.GetPlaylist(r.Context(), id) - if err != nil { - api.RespondError(w, http.StatusInternalServerError, err.Error()) - return - } - if detail == nil { - api.RespondError(w, http.StatusNotFound, "playlist not found") - return - } - - api.RespondJSON(w, http.StatusOK, detail) -} - -// Update handles PATCH /playlists/{id}. -func (h *PlaylistHandler) Update(w http.ResponseWriter, r *http.Request) { +// Rename handles PATCH /playlists/{id}, PUT /playlists/{id}, PATCH /me/playlists/{id}. +func (h *PlaylistHandler) Rename(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserID(r.Context()) if !ok { api.RespondError(w, http.StatusUnauthorized, "Authentication required") @@ -141,35 +144,46 @@ func (h *PlaylistHandler) Update(w http.ResponseWriter, r *http.Request) { } id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } if id == "" { api.RespondError(w, http.StatusBadRequest, "playlist id is required") return } var payload struct { - Title string `json:"title"` - Description string `json:"description"` - CoverURL string `json:"cover_url"` + Name string `json:"name"` + Title string `json:"title"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { api.RespondError(w, http.StatusBadRequest, "invalid request body") return } - updated, err := h.favSvc.UpdatePlaylist(r.Context(), id, userID, payload.Title, payload.Description, payload.CoverURL) + name := strings.TrimSpace(payload.Name) + if name == "" { + name = strings.TrimSpace(payload.Title) + } + if name == "" { + api.RespondError(w, http.StatusBadRequest, "name is required") + return + } + + updated, err := h.favSvc.RenamePlaylist(r.Context(), id, userID, name) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } if updated == nil { - api.RespondError(w, http.StatusNotFound, "playlist not found or unauthorized") + api.RespondError(w, http.StatusNotFound, "playlist not found") return } api.RespondJSON(w, http.StatusOK, updated) } -// Delete handles DELETE /playlists/{id}. +// Delete handles DELETE /playlists/{id} and DELETE /me/playlists/{id}. func (h *PlaylistHandler) Delete(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserID(r.Context()) if !ok { @@ -179,53 +193,42 @@ func (h *PlaylistHandler) Delete(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { - api.RespondError(w, http.StatusBadRequest, "playlist id is required") - return - } - - deleted, err := h.favSvc.DeletePlaylist(r.Context(), id, userID) - if err != nil { - api.RespondError(w, http.StatusInternalServerError, err.Error()) - return + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) } - - api.RespondJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "deleted": deleted, - }) -} - -// GetTracks handles GET /playlists/{id}/tracks. -func (h *PlaylistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { - id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { api.RespondError(w, http.StatusBadRequest, "playlist id is required") return } - tracks, err := h.favSvc.GetPlaylistTracks(r.Context(), id) - if err != nil { + if err := h.favSvc.DeletePlaylist(r.Context(), id, userID); err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } api.RespondJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "total": len(tracks), - "items": tracks, + "ok": true, }) } -// AddTracks handles POST /playlists/{id}/tracks. +// AddTracks handles POST /playlists/{id}/tracks and POST /me/playlists/{id}/tracks. func (h *PlaylistHandler) AddTracks(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } if id == "" { api.RespondError(w, http.StatusBadRequest, "playlist id is required") return } var payload struct { - TrackID string `json:"track_id"` + TrackID any `json:"track_id"` TrackIDs []string `json:"track_ids"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { @@ -234,38 +237,70 @@ func (h *PlaylistHandler) AddTracks(w http.ResponseWriter, r *http.Request) { } var trackIDs []string - if len(payload.TrackIDs) > 0 { - trackIDs = payload.TrackIDs - } else if payload.TrackID != "" { - trackIDs = []string{payload.TrackID} + if payload.TrackID != nil { + switch v := payload.TrackID.(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + trackIDs = append(trackIDs, s) + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + trackIDs = append(trackIDs, strings.TrimSpace(s)) + } + } + } + } + for _, tid := range payload.TrackIDs { + if s := strings.TrimSpace(tid); s != "" { + trackIDs = append(trackIDs, s) + } } if len(trackIDs) == 0 { - api.RespondError(w, http.StatusBadRequest, "track_ids cannot be empty") + api.RespondError(w, http.StatusBadRequest, "track_id or track_ids is required") return } - if err := h.favSvc.AddTracksToPlaylist(r.Context(), id, trackIDs); err != nil { + added, err := h.favSvc.AddTracksToPlaylist(r.Context(), id, userID, trackIDs) + if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } + if len(trackIDs) == 1 { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": added == 0, + }) + return + } + api.RespondJSON(w, http.StatusOK, map[string]any{ "ok": true, - "count": len(trackIDs), + "added": added, }) } -// RemoveTrack handles DELETE /playlists/{id}/tracks/{track_id}. +// RemoveTrack handles DELETE /playlists/{id}/tracks/{track_id} and DELETE /me/playlists/{id}/tracks/{track_id}. func (h *PlaylistHandler) RemoveTrack(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } trackID := strings.TrimSpace(chi.URLParam(r, "track_id")) if id == "" || trackID == "" { api.RespondError(w, http.StatusBadRequest, "playlist id and track id are required") return } - if err := h.favSvc.RemoveTrackFromPlaylist(r.Context(), id, trackID); err != nil { + if err := h.favSvc.RemoveTrackFromPlaylist(r.Context(), id, userID, trackID); err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } @@ -276,28 +311,134 @@ func (h *PlaylistHandler) RemoveTrack(w http.ResponseWriter, r *http.Request) { }) } -// Reorder handles POST /playlists/{id}/reorder. -func (h *PlaylistHandler) Reorder(w http.ResponseWriter, r *http.Request) { +// GetTracks handles GET /playlists/{id}/tracks and GET /me/playlists/{id}/tracks. +func (h *PlaylistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } if id == "" { api.RespondError(w, http.StatusBadRequest, "playlist id is required") return } + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 50) + + resp, err := h.favSvc.GetPlaylistTracks(r.Context(), id, userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetSharedPlaylist handles GET /share/playlists/{id} and GET /playlists/{id}. +func (h *PlaylistHandler) GetSharedPlaylist(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + resp, err := h.favSvc.GetSharedPlaylist(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusNotFound, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// SaveAlbum handles POST /albums and POST /me/albums. +func (h *PlaylistHandler) SaveAlbum(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + var payload struct { - TrackIDs []string `json:"track_ids"` + AlbumID any `json:"album_id"` + AlbumIDs []string `json:"album_ids"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { api.RespondError(w, http.StatusBadRequest, "invalid request body") return } - if err := h.favSvc.ReorderPlaylistTracks(r.Context(), id, payload.TrackIDs); err != nil { + var albumIDs []string + if payload.AlbumID != nil { + switch v := payload.AlbumID.(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + albumIDs = append(albumIDs, s) + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + albumIDs = append(albumIDs, strings.TrimSpace(s)) + } + } + } + } + for _, aid := range payload.AlbumIDs { + if s := strings.TrimSpace(aid); s != "" { + albumIDs = append(albumIDs, s) + } + } + + if len(albumIDs) == 0 { + api.RespondError(w, http.StatusBadRequest, "album_id or album_ids is required") + return + } + + added, err := h.favSvc.SaveAlbums(r.Context(), userID, albumIDs) + if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return } + if len(albumIDs) == 1 { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": added == 0, + }) + return + } + api.RespondJSON(w, http.StatusOK, map[string]any{ - "ok": true, + "ok": true, + "added": added, }) } + +// ListAlbums handles GET /albums and GET /me/albums. +func (h *PlaylistHandler) ListAlbums(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 50) + + resp, err := h.favSvc.GetSavedAlbums(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/playlists_favourites_discord_test.go b/internal/api/handlers/playlists_favourites_discord_test.go new file mode 100644 index 0000000..b1681ac --- /dev/null +++ b/internal/api/handlers/playlists_favourites_discord_test.go @@ -0,0 +1,412 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +type mockFavRepoForHandler struct { + playlists map[string]*models.UserPlaylist + tracks map[string][]string + favs map[int64][]string + artists map[int64][]string + albums map[int64][]string +} + +func (m *mockFavRepoForHandler) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + for _, id := range m.favs[userID] { + if id == trackID { + return true, nil + } + } + m.favs[userID] = append(m.favs[userID], trackID) + return false, nil +} + +func (m *mockFavRepoForHandler) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + ids := m.favs[userID] + for i, id := range ids { + if id == trackID { + m.favs[userID] = append(ids[:i], ids[i+1:]...) + return true, nil + } + } + return false, nil +} + +func (m *mockFavRepoForHandler) GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + ids := m.favs[userID] + return ids, int64(len(ids)), nil, nil +} + +func (m *mockFavRepoForHandler) GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + ids := m.favs[userID] + return ids, int64(len(ids)), nil, nil +} + +func (m *mockFavRepoForHandler) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + for _, id := range m.artists[userID] { + if id == artistID { + return true, nil + } + } + m.artists[userID] = append(m.artists[userID], artistID) + return false, nil +} + +func (m *mockFavRepoForHandler) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + ids := m.artists[userID] + for i, id := range ids { + if id == artistID { + m.artists[userID] = append(ids[:i], ids[i+1:]...) + return true, nil + } + } + return false, nil +} + +func (m *mockFavRepoForHandler) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { + return m.artists[userID], nil +} + +func (m *mockFavRepoForHandler) CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error { + m.playlists[p.ID] = p + return nil +} + +func (m *mockFavRepoForHandler) GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) { + var res []*models.UserPlaylist + for _, p := range m.playlists { + if p.UserID == userID { + res = append(res, p) + } + } + return res, nil +} + +func (m *mockFavRepoForHandler) GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) { + p, ok := m.playlists[playlistID] + if !ok || p.UserID != userID { + return nil, nil + } + return p, nil +} + +func (m *mockFavRepoForHandler) GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { + return m.playlists[playlistID], nil +} + +func (m *mockFavRepoForHandler) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) { + p := m.playlists[playlistID] + if p == nil || p.UserID != userID { + return nil, nil + } + p.Name = name + return p, nil +} + +func (m *mockFavRepoForHandler) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { + delete(m.playlists, playlistID) + return nil +} + +func (m *mockFavRepoForHandler) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) { + m.tracks[playlistID] = append(m.tracks[playlistID], trackIDs...) + return len(trackIDs), nil +} + +func (m *mockFavRepoForHandler) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { + ids := m.tracks[playlistID] + for i, id := range ids { + if id == trackID { + m.tracks[playlistID] = append(ids[:i], ids[i+1:]...) + return nil + } + } + return nil +} + +func (m *mockFavRepoForHandler) GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) { + ids := m.tracks[playlistID] + return ids, int64(len(ids)), nil +} + +func (m *mockFavRepoForHandler) GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) { + ids := m.tracks[playlistID] + if len(ids) > limit { + return ids[:limit], nil + } + return ids, nil +} + +func (m *mockFavRepoForHandler) SaveAlbum(ctx context.Context, userID int64, albumID string) (bool, error) { + for _, id := range m.albums[userID] { + if id == albumID { + return true, nil + } + } + m.albums[userID] = append(m.albums[userID], albumID) + return false, nil +} + +func (m *mockFavRepoForHandler) GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) ([]string, []float64, int64, error) { + ids := m.albums[userID] + ts := make([]float64, len(ids)) + return ids, ts, int64(len(ids)), nil +} + +func (m *mockFavRepoForHandler) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { + return len(events), nil +} + +func (m *mockFavRepoForHandler) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) { + return []string{}, nil +} + +func (m *mockFavRepoForHandler) GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) { + return []string{}, 0, nil +} + +type mockTrackRepoForHandler struct{} + +func (m *mockTrackRepoForHandler) GetByID(ctx context.Context, id string) (*models.Track, error) { + return &models.Track{ID: id, Audio: models.AudioMeta{Title: "Test", Artist: "Artist"}}, nil +} + +func (m *mockTrackRepoForHandler) GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) { + var res []*models.Track + for _, id := range ids { + res = append(res, &models.Track{ID: id, Audio: models.AudioMeta{Title: "Test", Artist: "Artist"}}) + } + return res, nil +} + +func (m *mockTrackRepoForHandler) List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) { + return nil, 0, nil +} + +func (m *mockTrackRepoForHandler) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) { + return nil, nil +} + +func (m *mockTrackRepoForHandler) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) { + return nil, nil +} + +func (m *mockTrackRepoForHandler) GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) { + return []*models.TopicItem{ + { + Name: "EDM", + TopicName: "EDM", + Count: 15, + TracksCount: 15, + CoverURL: "https://example.com/edm.jpg", + Endpoint: "/topics/EDM/tracks", + Thumbnails: []string{"https://example.com/edm.jpg"}, + }, + }, nil +} + +func (m *mockTrackRepoForHandler) GetChannelIDs(ctx context.Context) ([]int64, error) { + return nil, nil +} + +func (m *mockTrackRepoForHandler) IncrementPlayCount(ctx context.Context, id string) error { + return nil +} + +func (m *mockTrackRepoForHandler) UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error { + return nil +} + +type mockUserRepoForHandler struct{} + +func (m *mockUserRepoForHandler) UpsertUser(ctx context.Context, user *models.User) (*models.User, error) { + return user, nil +} +func (m *mockUserRepoForHandler) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + return &models.User{ID: id, Username: "test"}, nil +} +func (m *mockUserRepoForHandler) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + return &models.User{ID: 100, Username: username}, nil +} +func (m *mockUserRepoForHandler) UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error { + return nil +} +func (m *mockUserRepoForHandler) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { + return &models.UserIntegrations{}, nil +} +func (m *mockUserRepoForHandler) SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error { + return nil +} +func (m *mockUserRepoForHandler) GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) { + return nil, nil +} +func (m *mockUserRepoForHandler) DeleteRegistrationOTP(ctx context.Context, userID int64) error { + return nil +} +func (m *mockUserRepoForHandler) SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error { + return nil +} +func (m *mockUserRepoForHandler) GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) { + return nil, nil +} +func (m *mockUserRepoForHandler) GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) { + return nil, nil +} +func (m *mockUserRepoForHandler) SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error { + return nil +} +func (m *mockUserRepoForHandler) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { + return nil +} +func (m *mockUserRepoForHandler) SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error { + return nil +} +func (m *mockUserRepoForHandler) GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) { + return nil, nil +} + +func TestPlaylistsAndFavouritesHandlers(t *testing.T) { + cfg := &config.Config{SecretKey: "secret"} + authSvc := services.NewAuthService(cfg, &mockUserRepoForHandler{}) + token, _ := authSvc.CreateToken(&models.User{ID: 12345, Username: "testuser"}) + + favRepo := &mockFavRepoForHandler{ + playlists: make(map[string]*models.UserPlaylist), + tracks: make(map[string][]string), + favs: make(map[int64][]string), + artists: make(map[int64][]string), + albums: make(map[int64][]string), + } + trackRepo := &mockTrackRepoForHandler{} + favSvc := services.NewFavouritePlaylistService(favRepo, trackRepo, nil) + discordSvc := services.NewDiscordService() + + playlistH := NewPlaylistHandler(favSvc, authSvc) + favH := NewFavouriteHandler(favSvc, authSvc) + discordH := NewDiscordHandler(discordSvc) + + r := chi.NewRouter() + playlistH.Routes(r) + favH.Routes(r) + discordH.Routes(r) + + // 1. Create playlist + body := `{"name": "Vibes"}` + req := httptest.NewRequest(http.MethodPost, "/me/playlists", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var pResp models.PlaylistItem + _ = json.Unmarshal(w.Body.Bytes(), &pResp) + if pResp.Name != "Vibes" { + t.Errorf("expected playlist name Vibes, got %s", pResp.Name) + } + + // 2. Add Track to playlist + addBody := `{"track_id": "trk_99"}` + req = httptest.NewRequest(http.MethodPost, "/me/playlists/"+pResp.PlaylistID+"/tracks", bytes.NewBufferString(addBody)) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // 3. Add favourite track + favBody := `{"track_id": "trk_fav_1"}` + req = httptest.NewRequest(http.MethodPost, "/me/favourites", bytes.NewBufferString(favBody)) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // 4. Get favourite track IDs + req = httptest.NewRequest(http.MethodGet, "/me/favourites/ids", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // 5. Discord remote auth status for missing session -> 404 + req = httptest.NewRequest(http.MethodGet, "/api/discord/remote-auth/status/non-existent", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for non-existent discord session, got %d", w.Code) + } +} + +func TestTopicsAndAuthEndpoints(t *testing.T) { + cfg := &config.Config{SecretKey: "secret"} + authSvc := services.NewAuthService(cfg, &mockUserRepoForHandler{}) + trackRepo := &mockTrackRepoForHandler{} + trackSvc := services.NewTrackService(trackRepo) + topicH := NewTopicHandler(trackSvc) + authH := NewAuthHandler(cfg, authSvc) + + r := chi.NewRouter() + topicH.Routes(r) + authH.Routes(r) + + // 1. GET /topics + req := httptest.NewRequest(http.MethodGet, "/topics", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var topicsResp models.TopicsResponse + if err := json.Unmarshal(w.Body.Bytes(), &topicsResp); err != nil { + t.Fatalf("failed to unmarshal topics: %v", err) + } + if !topicsResp.OK || topicsResp.Total != 1 || len(topicsResp.Items) != 1 || len(topicsResp.Topics) != 1 { + t.Fatalf("unexpected topics response: %+v", topicsResp) + } + if topicsResp.Topics[0] != "EDM" || topicsResp.Items[0].Name != "EDM" || topicsResp.Items[0].Count != 15 { + t.Fatalf("unexpected topic item data: %+v", topicsResp.Items[0]) + } + + // 2. POST /auth/fcm-token with valid auth token + token, _ := authSvc.CreateToken(&models.User{ID: 100, Username: "test"}) + req = httptest.NewRequest(http.MethodPost, "/auth/fcm-token", bytes.NewBufferString(`{"fcm_token": "token_xyz"}`)) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // 3. POST /auth/telegram/bot-session + req = httptest.NewRequest(http.MethodPost, "/auth/telegram/bot-session", bytes.NewBufferString(`{"invite_code": "INV123"}`)) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var sessResp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &sessResp) + if sessResp["ok"] != true || sessResp["session_id"] == nil { + t.Fatalf("unexpected bot session response: %+v", sessResp) + } +} diff --git a/internal/api/handlers/share.go b/internal/api/handlers/share.go new file mode 100644 index 0000000..40626b9 --- /dev/null +++ b/internal/api/handlers/share.go @@ -0,0 +1,861 @@ +package handlers + +import ( + "fmt" + "html" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// ShareHandler handles rich embeds, OpenGraph/Twitter card previews, and share redirects. +type ShareHandler struct { + trackRepo repository.TrackRepository + playlistRepo repository.FavouritePlaylistRepository + cfg *config.Config +} + +// NewShareHandler creates a new ShareHandler. +func NewShareHandler(trackRepo repository.TrackRepository, playlistRepo repository.FavouritePlaylistRepository, cfg *config.Config) *ShareHandler { + return &ShareHandler{ + trackRepo: trackRepo, + playlistRepo: playlistRepo, + cfg: cfg, + } +} + +// Routes mounts /share endpoints. +func (h *ShareHandler) Routes(r chi.Router) { + r.Get("/share/{id}", h.HandleShare) + r.Get("/share/tracks/{id}", h.HandleTrackShare) + r.Get("/share/playlists/{id}", h.HandlePlaylistShare) +} + +// HandleShare detects whether id is a track or playlist and renders the preview. +func (h *ShareHandler) HandleShare(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + http.Error(w, "Share ID required", http.StatusBadRequest) + return + } + + // 1. Try track first + track, err := h.trackRepo.GetByID(r.Context(), id) + if err == nil && track != nil { + h.renderTrack(w, r, track) + return + } + + // 2. Try playlist + playlist, err := h.playlistRepo.GetPublicPlaylistByID(r.Context(), id) + if err == nil && playlist != nil { + h.renderPlaylist(w, r, playlist) + return + } + + http.Error(w, "Track or playlist not found", http.StatusNotFound) +} + +// HandleTrackShare renders track preview. +func (h *ShareHandler) HandleTrackShare(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + track, err := h.trackRepo.GetByID(r.Context(), id) + if err != nil || track == nil { + http.Error(w, "Track not found", http.StatusNotFound) + return + } + h.renderTrack(w, r, track) +} + +// HandlePlaylistShare renders playlist preview. +func (h *ShareHandler) HandlePlaylistShare(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + playlist, err := h.playlistRepo.GetPublicPlaylistByID(r.Context(), id) + if err != nil || playlist == nil { + http.Error(w, "Playlist not found", http.StatusNotFound) + return + } + h.renderPlaylist(w, r, playlist) +} + +func (h *ShareHandler) wantsJSON(r *http.Request) bool { + if r.URL.Query().Get("format") == "json" { + return true + } + accept := strings.ToLower(r.Header.Get("Accept")) + return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") +} + +func (h *ShareHandler) renderTrack(w http.ResponseWriter, r *http.Request, track *models.Track) { + title := track.Audio.Title + if title == "" { + title = "Unknown Title" + } + artist := track.Audio.Artist + if artist == "" { + artist = "Unknown Artist" + } + album := track.Audio.Album + + baseURL := h.baseURL(r) + shareURL := fmt.Sprintf("%s/share/%s", baseURL, track.ID) + streamURL := fmt.Sprintf("%s/stream/%s", baseURL, track.ID) + downloadURL := fmt.Sprintf("%s/tracks/%s/download", baseURL, track.ID) + coverURL := fmt.Sprintf("%s/stream/cover/%s", baseURL, track.ID) + + desc := fmt.Sprintf("Listen to %s by %s on StreamX.", title, artist) + if album != "" { + desc = fmt.Sprintf("Listen to %s by %s from the album %s on StreamX.", title, artist, album) + } + + if h.wantsJSON(r) { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "type": "track", + "id": track.ID, + "title": title, + "artist": artist, + "album": album, + "duration_sec": track.Audio.DurationSec, + "audio_type": track.Audio.Type, + "cover_url": coverURL, + "stream_url": streamURL, + "download_url": downloadURL, + "share_url": shareURL, + "open_graph": map[string]string{ + "og:title": fmt.Sprintf("%s - %s", title, artist), + "og:description": desc, + "og:image": coverURL, + "og:audio": streamURL, + "og:type": "music.song", + }, + }) + return + } + + durationFormatted := formatDuration(int(track.Audio.DurationSec)) + pageTitle := html.EscapeString(fmt.Sprintf("%s – %s | StreamX", title, artist)) + escTitle := html.EscapeString(title) + escArtist := html.EscapeString(artist) + escAlbum := html.EscapeString(album) + escDesc := html.EscapeString(desc) + escType := strings.ToUpper(track.Audio.Type) + if escType == "" { + escType = "AUDIO" + } + + // Extra metadata + bitrateInfo := "" + if track.Audio.BitrateKbps != nil && *track.Audio.BitrateKbps > 0 { + bitrateInfo = fmt.Sprintf("%d kbps", *track.Audio.BitrateKbps) + } + sampleInfo := "" + if track.Audio.SamplingRateHz != nil && *track.Audio.SamplingRateHz > 0 { + sampleInfo = fmt.Sprintf("%.1f kHz", float64(*track.Audio.SamplingRateHz)/1000.0) + } + bitDepthInfo := "" + if track.Audio.BitDepth != nil && *track.Audio.BitDepth > 0 { + bitDepthInfo = fmt.Sprintf("%d-bit", *track.Audio.BitDepth) + } + qualityParts := []string{} + if bitDepthInfo != "" { + qualityParts = append(qualityParts, bitDepthInfo) + } + if sampleInfo != "" { + qualityParts = append(qualityParts, sampleInfo) + } + if bitrateInfo != "" { + qualityParts = append(qualityParts, bitrateInfo) + } + qualityStr := "" + if len(qualityParts) > 0 { + qualityStr = html.EscapeString(strings.Join(qualityParts, " · ")) + } + + yearStr := "" + if track.Audio.Year != nil && *track.Audio.Year > 0 { + yearStr = fmt.Sprintf("%d", *track.Audio.Year) + } + genreStr := html.EscapeString(track.Audio.Genre) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + + + + + %s + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ %s + %s +
+
+
+
+

%s

+
%s
+ %s + %s +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + + +
+
+ + +`, + pageTitle, + shareURL, escTitle, escArtist, escDesc, coverURL, streamURL, escArtist, track.Audio.DurationSec, + shareURL, escTitle, escArtist, escDesc, coverURL, + coverURL, escTitle, escType, + escTitle, escArtist, + renderAlbumLine(escAlbum, yearStr), + renderMetaChips(qualityStr, genreStr), + streamURL, + streamURL, + downloadURL, + durationFormatted, + ) +} + +func (h *ShareHandler) renderPlaylist(w http.ResponseWriter, r *http.Request, playlist *models.UserPlaylist) { + name := playlist.Name + if name == "" { + name = "StreamX Playlist" + } + + baseURL := h.baseURL(r) + shareURL := fmt.Sprintf("%s/share/playlists/%s", baseURL, playlist.ID) + coverURL := playlist.CoverURL + if coverURL == "" { + coverURL = fmt.Sprintf("%s/share/cover/default.jpg", baseURL) + } + + _, trackCount, _ := h.playlistRepo.GetPlaylistTrackIDs(r.Context(), playlist.ID, 1, 1) + desc := fmt.Sprintf("Stream %s featuring %d tracks on StreamX.", name, trackCount) + + if h.wantsJSON(r) { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "type": "playlist", + "id": playlist.ID, + "name": name, + "track_count": trackCount, + "cover_url": coverURL, + "share_url": shareURL, + "open_graph": map[string]string{ + "og:title": name, + "og:description": desc, + "og:image": coverURL, + "og:type": "music.playlist", + }, + }) + return + } + + pageTitle := html.EscapeString(fmt.Sprintf("%s | StreamX Playlist", name)) + escName := html.EscapeString(name) + escDesc := html.EscapeString(desc) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + + + + + %s + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ %s + PLAYLIST + %d tracks +
+
+
+

%s

+
%d tracks in this playlist
+
+ + + Open in StreamX + + +
+
+ +`, + pageTitle, + shareURL, escName, escDesc, coverURL, + shareURL, escName, escDesc, coverURL, + coverURL, escName, + trackCount, + escName, trackCount, + shareURL, + ) +} + +func (h *ShareHandler) baseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Host + if host == "" { + host = "localhost:8000" + } + return fmt.Sprintf("%s://%s", scheme, host) +} + +func renderAlbumLine(album, year string) string { + if album == "" && year == "" { + return "" + } + if album != "" && year != "" { + return fmt.Sprintf(`
%s · %s
`, album, year) + } + if album != "" { + return fmt.Sprintf(`
%s
`, album) + } + return fmt.Sprintf(`
%s
`, year) +} + +func renderMetaChips(quality, genre string) string { + if quality == "" && genre == "" { + return "" + } + out := `
` + if quality != "" { + out += fmt.Sprintf(`%s`, quality) + } + if genre != "" { + out += fmt.Sprintf(`%s`, genre) + } + out += `
` + return out +} + +func formatDuration(sec int) string { + if sec <= 0 { + return "0:00" + } + m := sec / 60 + s := sec % 60 + return fmt.Sprintf("%d:%02d", m, s) +} diff --git a/internal/api/handlers/sources.go b/internal/api/handlers/sources.go new file mode 100644 index 0000000..ff2db09 --- /dev/null +++ b/internal/api/handlers/sources.go @@ -0,0 +1,298 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/config" + "streamgo/internal/services" +) + +// SourcesHandler manages admin source filtering and ingestion controls. +type SourcesHandler struct { + cfg *config.Config + filter *services.AccessFilter + authSvc *services.AuthService + + mu sync.RWMutex + allowed []map[string]any + banned []map[string]any +} + +// NewSourcesHandler creates a new SourcesHandler instance. +func NewSourcesHandler(cfg *config.Config, filter *services.AccessFilter, authSvc *services.AuthService) *SourcesHandler { + return &SourcesHandler{ + cfg: cfg, + filter: filter, + authSvc: authSvc, + allowed: make([]map[string]any, 0), + banned: make([]map[string]any, 0), + } +} + +// Routes mounts the admin sources endpoints. +func (h *SourcesHandler) Routes(r chi.Router) { + r.Route("/admin/sources", func(sub chi.Router) { + sub.Use(h.requireAdminAuth) + + sub.Get("/", h.GetOverview) + + sub.Get("/mode", h.GetMode) + sub.Post("/mode", h.SetMode) + + sub.Get("/allowed", h.ListAllowed) + sub.Post("/allowed", h.AddAllowed) + sub.Delete("/allowed/{source_id}", h.RemoveAllowed) + + sub.Get("/banned", h.ListBanned) + sub.Post("/banned", h.AddBanned) + sub.Delete("/banned/{source_id}", h.RemoveBanned) + }) +} + +func (h *SourcesHandler) requireAdminAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Secret-Key") + if h.cfg != nil && h.cfg.SecretKey != "" && secret == h.cfg.SecretKey { + next.ServeHTTP(w, r) + return + } + api.RespondError(w, http.StatusUnauthorized, "Admin authentication required") + }) +} + +// GetOverview returns summary of filter mode and allowed/banned sources. +func (h *SourcesHandler) GetOverview(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + + mode := services.FilterModeGroupOnly + if h.cfg != nil { + mode = h.cfg.FilterMode + } + + var channelID int64 + collaborators := []int64{} + if h.cfg != nil { + channelID = h.cfg.ChannelID + collaborators = h.cfg.CollaboratorIDs + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "filter_mode": mode, + "filter_mode_name": services.FilterModeToString(mode), + "channel_id": channelID, + "collaborator_ids": collaborators, + "allowed_sources_count": len(h.allowed), + "banned_sources_count": len(h.banned), + "allowed_sources": h.allowed, + "banned_sources": h.banned, + }) +} + +// GetMode returns the current filter mode. +func (h *SourcesHandler) GetMode(w http.ResponseWriter, r *http.Request) { + mode := services.FilterModeGroupOnly + if h.cfg != nil { + mode = h.cfg.FilterMode + } + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "filter_mode": mode, + "filter_mode_name": services.FilterModeToString(mode), + }) +} + +// SetMode updates the active filter mode. +func (h *SourcesHandler) SetMode(w http.ResponseWriter, r *http.Request) { + var payload struct { + Mode any `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + parsed := services.ParseFilterMode(payload.Mode) + if h.cfg != nil { + h.cfg.FilterMode = parsed + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "filter_mode": parsed, + "filter_mode_name": services.FilterModeToString(parsed), + }) +} + +// ListAllowed returns all allowed sources. +func (h *SourcesHandler) ListAllowed(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(h.allowed), + "sources": h.allowed, + }) +} + +// AddAllowed adds a source to allowed list. +func (h *SourcesHandler) AddAllowed(w http.ResponseWriter, r *http.Request) { + var payload struct { + SourceID any `json:"source_id"` + SourceType string `json:"source_type"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var sid int64 + switch v := payload.SourceID.(type) { + case float64: + sid = int64(v) + case int64: + sid = v + case int: + sid = int64(v) + case string: + sid, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64) + } + + doc := map[string]any{ + "source_id": sid, + "source_type": payload.SourceType, + "name": payload.Name, + "created_at": time.Now().Unix(), + } + + h.mu.Lock() + h.allowed = append(h.allowed, doc) + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source": doc, + }) +} + +// RemoveAllowed removes a source from allowed list. +func (h *SourcesHandler) RemoveAllowed(w http.ResponseWriter, r *http.Request) { + sidStr := chi.URLParam(r, "source_id") + sid, err := strconv.ParseInt(sidStr, 10, 64) + if err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid source_id") + return + } + + h.mu.Lock() + newAllowed := make([]map[string]any, 0, len(h.allowed)) + removed := false + for _, item := range h.allowed { + if id, ok := item["source_id"].(int64); ok && id == sid { + removed = true + continue + } + newAllowed = append(newAllowed, item) + } + h.allowed = newAllowed + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source_id": sid, + "removed": removed, + }) +} + +// ListBanned returns all banned sources. +func (h *SourcesHandler) ListBanned(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(h.banned), + "sources": h.banned, + }) +} + +// AddBanned bans a source. +func (h *SourcesHandler) AddBanned(w http.ResponseWriter, r *http.Request) { + var payload struct { + SourceID any `json:"source_id"` + SourceType string `json:"source_type"` + Name string `json:"name"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var sid int64 + switch v := payload.SourceID.(type) { + case float64: + sid = int64(v) + case int64: + sid = v + case int: + sid = int64(v) + case string: + sid, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64) + } + + doc := map[string]any{ + "source_id": sid, + "source_type": payload.SourceType, + "name": payload.Name, + "reason": payload.Reason, + "created_at": time.Now().Unix(), + } + + h.mu.Lock() + h.banned = append(h.banned, doc) + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source": doc, + }) +} + +// RemoveBanned unbans a source. +func (h *SourcesHandler) RemoveBanned(w http.ResponseWriter, r *http.Request) { + sidStr := chi.URLParam(r, "source_id") + sid, err := strconv.ParseInt(sidStr, 10, 64) + if err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid source_id") + return + } + + h.mu.Lock() + newBanned := make([]map[string]any, 0, len(h.banned)) + removed := false + for _, item := range h.banned { + if id, ok := item["source_id"].(int64); ok && id == sid { + removed = true + continue + } + newBanned = append(newBanned, item) + } + h.banned = newBanned + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source_id": sid, + "removed": removed, + }) +} diff --git a/internal/api/handlers/sources_test.go b/internal/api/handlers/sources_test.go new file mode 100644 index 0000000..93cc29b --- /dev/null +++ b/internal/api/handlers/sources_test.go @@ -0,0 +1,143 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/config" + "streamgo/internal/services" +) + +func setupSourcesTestRouter() (*chi.Mux, *services.AccessFilter) { + cfg := &config.Config{ + ChannelID: -1003961478268, + DumpChannelID: -1004300252384, + SecretKey: "test-secret", + } + + filter := services.NewAccessFilter(cfg, nil) + handler := NewSourcesHandler(cfg, filter, nil) + + r := chi.NewRouter() + handler.Routes(r) + return r, filter +} + +func TestSourcesOverviewAndModeEndpoints(t *testing.T) { + router, _ := setupSourcesTestRouter() + + // 1. GET /admin/sources without auth -> 401 + req := httptest.NewRequest(http.MethodGet, "/admin/sources/", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 unauthorized, got %d", w.Code) + } + + // 2. GET /admin/sources with X-Secret-Key -> 200 + req = httptest.NewRequest(http.MethodGet, "/admin/sources/", nil) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + var overviewResp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &overviewResp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if overviewResp["ok"] != true { + t.Errorf("expected ok: true") + } + + // 3. GET /admin/sources/mode + req = httptest.NewRequest(http.MethodGet, "/admin/sources/mode", nil) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + + // 4. POST /admin/sources/mode to "anyone" + setModeBody := `{"mode": "anyone"}` + req = httptest.NewRequest(http.MethodPost, "/admin/sources/mode", bytes.NewBufferString(setModeBody)) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + var modeResp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &modeResp) + if modeResp["filter_mode_name"] != "anyone" { + t.Errorf("expected filter_mode_name anyone, got %v", modeResp["filter_mode_name"]) + } +} + +func TestSourcesAllowedAndBannedEndpoints(t *testing.T) { + router, _ := setupSourcesTestRouter() + + // 1. POST /admin/sources/allowed + allowedBody := `{"source_id": -100123456789, "source_type": "channel", "name": "Test Channel"}` + req := httptest.NewRequest(http.MethodPost, "/admin/sources/allowed", bytes.NewBufferString(allowedBody)) + req.Header.Set("X-Secret-Key", "test-secret") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + // 2. GET /admin/sources/allowed + req = httptest.NewRequest(http.MethodGet, "/admin/sources/allowed", nil) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + + // 3. DELETE /admin/sources/allowed/-100123456789 + req = httptest.NewRequest(http.MethodDelete, "/admin/sources/allowed/-100123456789", nil) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + + // 4. POST /admin/sources/banned + banBody := `{"source_id": 987654321, "source_type": "user", "name": "Bad Actor", "reason": "Spamming"}` + req = httptest.NewRequest(http.MethodPost, "/admin/sources/banned", bytes.NewBufferString(banBody)) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + // 5. GET /admin/sources/banned + req = httptest.NewRequest(http.MethodGet, "/admin/sources/banned", nil) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + + // 6. DELETE /admin/sources/banned/987654321 + req = httptest.NewRequest(http.MethodDelete, "/admin/sources/banned/987654321", nil) + req.Header.Set("X-Secret-Key", "test-secret") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index 339bdc2..ceb74ca 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -1,7 +1,10 @@ package handlers import ( + "context" + "fmt" "net/http" + "net/url" "strings" "github.com/go-chi/chi/v5" @@ -10,7 +13,7 @@ import ( "streamgo/internal/services" ) -// StreamHandler handles audio streaming endpoints. +// StreamHandler handles audio streaming and download endpoints. type StreamHandler struct { streamService *services.StreamService } @@ -20,12 +23,19 @@ func NewStreamHandler(svc *services.StreamService) *StreamHandler { return &StreamHandler{streamService: svc} } -// Routes mounts the streaming endpoints. +// Routes mounts the streaming and download endpoints. func (h *StreamHandler) Routes(r chi.Router) { r.Get("/tracks/{id}/stream", h.Stream) r.Head("/tracks/{id}/stream", h.Stream) r.Get("/stream/{id}", h.Stream) r.Head("/stream/{id}", h.Stream) + + r.Get("/tracks/{id}/download", h.Download) + r.Head("/tracks/{id}/download", h.Download) + r.Get("/download/{id}", h.Download) + r.Head("/download/{id}", h.Download) + + r.Get("/tracks/{id}/warm", h.Warm) } // Stream handles GET and HEAD /tracks/{id}/stream with HTTP Range support. @@ -39,3 +49,55 @@ func (h *StreamHandler) Stream(w http.ResponseWriter, r *http.Request) { h.streamService.StreamTrack(w, r, trackID) } + +// Download handles GET and HEAD /tracks/{id}/download with Content-Disposition attachment. +func (h *StreamHandler) Download(w http.ResponseWriter, r *http.Request) { + trackID := chi.URLParam(r, "id") + trackID = strings.TrimSpace(trackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + track, _ := h.streamService.GetTrack(r.Context(), trackID) + if track != nil { + filename := track.Audio.Title + if track.Audio.Artist != "" { + filename = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } + if filename == "" { + filename = track.Telegram.FileName + } + if filename == "" { + filename = fmt.Sprintf("track-%s", track.ID) + } + ext := track.Audio.Type + if ext == "" { + ext = "mp3" + } + if !strings.HasSuffix(strings.ToLower(filename), "."+strings.ToLower(ext)) { + filename = fmt.Sprintf("%s.%s", filename, ext) + } + fallback := strings.ReplaceAll(filename, `"`, `_`) + encoded := url.QueryEscape(filename) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + } else { + w.Header().Set("Content-Disposition", `attachment; filename="track.mp3"`) + } + + h.streamService.StreamTrack(w, r, trackID) +} + +// Warm handles GET /tracks/{id}/warm to prewarm playback cache. +func (h *StreamHandler) Warm(w http.ResponseWriter, r *http.Request) { + trackID := chi.URLParam(r, "id") + trackID = strings.TrimSpace(trackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + go h.streamService.WarmTrack(context.Background(), trackID) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + diff --git a/internal/api/handlers/tracks.go b/internal/api/handlers/tracks.go index 949ac35..46ce618 100644 --- a/internal/api/handlers/tracks.go +++ b/internal/api/handlers/tracks.go @@ -25,7 +25,10 @@ func (h *TrackHandler) Routes(r chi.Router) { r.Get("/tracks", h.List) r.Get("/browse", h.List) r.Get("/search", h.Search) + r.Get("/tracks/search", h.Search) r.Get("/tracks/random", h.Random) + r.Get("/tracks/shuffle", h.Random) + r.Get("/library/shuffle", h.Random) r.Get("/tracks/{id}", h.GetByID) } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index a183326..150110a 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -12,7 +12,8 @@ import ( type contextKey string const ( - userIDKey contextKey = "userID" + userIDKey contextKey = "userID" + isGuestKey contextKey = "isGuest" ) // WithUserID injects a numeric user ID into context. @@ -30,7 +31,23 @@ func GetUserID(ctx context.Context) (int64, bool) { return uid, ok && uid > 0 } +// WithGuest marks context as a guest session. +func WithGuest(ctx context.Context, isGuest bool) context.Context { + return context.WithValue(ctx, isGuestKey, isGuest) +} + +// IsGuest checks if current request is authenticated as guest. +func IsGuest(ctx context.Context) bool { + val := ctx.Value(isGuestKey) + if val == nil { + return false + } + g, ok := val.(bool) + return ok && g +} + func extractToken(r *http.Request) string { + // 1. Authorization: Bearer authHeader := strings.TrimSpace(r.Header.Get("Authorization")) if authHeader != "" { if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { @@ -39,6 +56,12 @@ func extractToken(r *http.Request) string { return authHeader } + // 2. X-Auth-Token header + if xAuth := strings.TrimSpace(r.Header.Get("X-Auth-Token")); xAuth != "" { + return xAuth + } + + // 3. Query string (?token=... or ?auth=...) if qToken := strings.TrimSpace(r.URL.Query().Get("token")); qToken != "" { return qToken } @@ -46,42 +69,93 @@ func extractToken(r *http.Request) string { return qAuth } + // 4. Cookies (auth_token or token) + if c, err := r.Cookie("auth_token"); err == nil && strings.TrimSpace(c.Value) != "" { + return strings.TrimSpace(c.Value) + } + if c, err := r.Cookie("token"); err == nil && strings.TrimSpace(c.Value) != "" { + return strings.TrimSpace(c.Value) + } + + return "" +} + +func extractGuestPassword(r *http.Request) string { + if gp := strings.TrimSpace(r.Header.Get("X-Guest-Password")); gp != "" { + return gp + } + if gp := strings.TrimSpace(r.URL.Query().Get("guest_password")); gp != "" { + return gp + } return "" } -// RequireAuth enforces a valid authentication token. +// RequireAuth enforces a valid authentication token or valid guest password. func RequireAuth(authSvc *services.AuthService) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. Check direct guest password bypass + if guestPwd := extractGuestPassword(r); guestPwd != "" { + if authSvc.VerifyGuestPassword(r.Context(), guestPwd) { + ctx := WithGuest(r.Context(), true) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + // 2. Check token token := extractToken(r) if token == "" { api.RespondError(w, http.StatusUnauthorized, "Authentication required") return } - userID, err := authSvc.VerifyToken(token) - if err != nil || userID <= 0 { + claims, err := authSvc.VerifyTokenClaims(token) + if err != nil { api.RespondError(w, http.StatusUnauthorized, "Invalid or expired authentication token") return } - ctx := WithUserID(r.Context(), userID) + if claims.IsGuest { + ctx := WithGuest(r.Context(), true) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + if claims.UserID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Invalid user ID in token") + return + } + + ctx := WithUserID(r.Context(), claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) } } -// OptionalAuth parses authentication token if present without rejecting unauthenticated requests. +// OptionalAuth parses authentication token or guest password without rejecting unauthenticated requests. func OptionalAuth(authSvc *services.AuthService) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if guestPwd := extractGuestPassword(r); guestPwd != "" { + if authSvc.VerifyGuestPassword(ctx, guestPwd) { + ctx = WithGuest(ctx, true) + } + } + token := extractToken(r) if token != "" { - if userID, err := authSvc.VerifyToken(token); err == nil && userID > 0 { - r = r.WithContext(WithUserID(r.Context(), userID)) + if claims, err := authSvc.VerifyTokenClaims(token); err == nil { + if claims.IsGuest { + ctx = WithGuest(ctx, true) + } else if claims.UserID > 0 { + ctx = WithUserID(ctx, claims.UserID) + } } } - next.ServeHTTP(w, r) + + next.ServeHTTP(w, r.WithContext(ctx)) }) } } diff --git a/internal/api/response.go b/internal/api/response.go index 91d5e9f..7c1f65f 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -53,3 +53,19 @@ func ParseQueryInt64(r *http.Request, key string, defaultVal int64) int64 { } return defaultVal } + +// ParseQueryBool parses a boolean query parameter with a fallback default. +func ParseQueryBool(r *http.Request, key string, defaultVal bool) bool { + val := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(key))) + if val == "" { + return defaultVal + } + if val == "1" || val == "true" || val == "yes" || val == "on" { + return true + } + if val == "0" || val == "false" || val == "no" || val == "off" { + return false + } + return defaultVal +} + diff --git a/internal/config/config.go b/internal/config/config.go index a361b1a..c22247a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "os" "strconv" "strings" @@ -13,22 +14,36 @@ var log = logger.New("config") // Config holds all configuration parameters for StreamGO. type Config struct { - Port string - Debug bool - APILogs bool - CorsOrigin string - MongoURI string - DatabaseName string - ApiID int - ApiHash string - BotToken string - SecretKey string - SessionString string - ChannelID int64 - DumpChannelID int64 - MultiClients bool - MultiClientTokens []string - FilterMode int + Port string + Debug bool + APILogs bool + CorsOrigin string + MongoURI string + DatabaseName string + ApiID int + ApiHash string + BotToken string + SecretKey string + SessionString string + ChannelID int64 + DumpChannelID int64 + MultiClients bool + MultiClientTokens []string + FilterMode int + CollaboratorIDs []int64 + Lyrics bool + LRCLIB bool + Musixmatch bool + TelegramOIDCClientID string + TelegramOIDCClientSecret string + TelegramOIDCOrigin string + TelegramOIDCRedirectURI string + CookieSecure bool + CookieSameSite string + OwnerIDs []int64 + SudoUsers []int64 + EnrichmentWorkers int + GuestPassword string } // Load reads configuration from .env file (if present) and environment variables. @@ -65,26 +80,92 @@ func Load() *Config { multiClients = true } + // Parse collaborator IDs from COLLABORATOR_ID and COLLABORATOR_IDS + collabList := parseIDList(os.Getenv("COLLABORATOR_IDS")) + if singleCollab := getEnvInt64("COLLABORATOR_ID", 0); singleCollab != 0 { + collabList = append(collabList, singleCollab) + } + + // Owner and Sudo users + ownerIDs := parseIDList(os.Getenv("OWNER_ID")) + if len(ownerIDs) == 0 { + if oid := getEnvInt64("OWNER_ID", 0); oid != 0 { + ownerIDs = []int64{oid} + } + } + sudoUsers := parseIDList(os.Getenv("SUDO_USERS")) + return &Config{ - Port: getEnv("PORT", "8000"), - Debug: debug, - APILogs: getEnvBool("API_LOGS", getEnvBool("HTTP_LOGS", false)), - CorsOrigin: getEnv("CORS_ORIGIN", "*"), - MongoURI: mongoURI, - DatabaseName: getEnv("DATABASE_NAME", "Stream"), - ApiID: getEnvInt("API_ID", 0), - ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), - BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), - SecretKey: strings.TrimSpace(os.Getenv("SECRET_KEY")), - SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), - ChannelID: getEnvInt64("CHANNEL_ID", 0), - DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), - MultiClients: multiClients, - MultiClientTokens: multiTokens, - FilterMode: getEnvInt("FILTER_MODE", 0), + Port: getEnv("PORT", "8000"), + Debug: debug, + APILogs: getEnvBool("API_LOGS", getEnvBool("HTTP_LOGS", false)), + CorsOrigin: getEnv("CORS_ORIGIN", "*"), + MongoURI: mongoURI, + DatabaseName: getEnv("DATABASE_NAME", "Stream"), + ApiID: getEnvInt("API_ID", 0), + ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), + BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), + SecretKey: strings.TrimSpace(os.Getenv("SECRET_KEY")), + SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), + ChannelID: getEnvInt64("CHANNEL_ID", 0), + DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), + MultiClients: multiClients, + MultiClientTokens: multiTokens, + FilterMode: getEnvInt("FILTER_MODE", 0), + CollaboratorIDs: collabList, + Lyrics: getEnvBool("LYRICS", true), + LRCLIB: getEnvBool("LRCLIB", false), + Musixmatch: getEnvBool("MUSIXMATCH", true), + TelegramOIDCClientID: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_CLIENT_ID")), + TelegramOIDCClientSecret: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_CLIENT_SECRET")), + TelegramOIDCOrigin: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_ORIGIN")), + TelegramOIDCRedirectURI: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_REDIRECT_URI")), + CookieSecure: getEnvBool("COOKIE_SECURE", false), + CookieSameSite: getEnv("COOKIE_SAMESITE", "lax"), + OwnerIDs: ownerIDs, + SudoUsers: sudoUsers, + EnrichmentWorkers: getEnvInt("ENRICHMENT_WORKERS", 2), + GuestPassword: getEnv("GUEST_PASSWORD", ""), } } +func parseIDList(raw string) []int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + // Try JSON array first + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + var arr []any + if err := json.Unmarshal([]byte(raw), &arr); err == nil { + var ids []int64 + for _, item := range arr { + switch v := item.(type) { + case float64: + ids = append(ids, int64(v)) + case string: + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { + ids = append(ids, n) + } + } + } + return ids + } + } + + // Delimiter split (comma, space) + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' + }) + var ids []int64 + for _, f := range fields { + if n, err := strconv.ParseInt(strings.TrimSpace(f), 10, 64); err == nil { + ids = append(ids, n) + } + } + return ids +} func getEnv(key, defaultVal string) string { if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { diff --git a/internal/models/access_control.go b/internal/models/access_control.go new file mode 100644 index 0000000..ca6984f --- /dev/null +++ b/internal/models/access_control.go @@ -0,0 +1,66 @@ +package models + +// RequiredChat represents a Telegram channel or group users must join. +type RequiredChat struct { + ChatID int64 `bson:"chat_id" json:"chat_id"` + Title string `bson:"title,omitempty" json:"title,omitempty"` + InviteLink string `bson:"invite_link,omitempty" json:"invite_link,omitempty"` + IsPrivate bool `bson:"is_private" json:"is_private"` +} + +// AccessPolicy defines global access and registration policy. +type AccessPolicy struct { + RegistrationMode string `bson:"registration_mode" json:"registration_mode"` // "open", "invite", "allowlist", "closed" + EnforceMembership bool `bson:"enforce_membership" json:"enforce_membership"` + LockMessage string `bson:"lock_message,omitempty" json:"lock_message,omitempty"` + RequiredChats []RequiredChat `bson:"required_chats" json:"required_chats"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` + UpdatedBy int64 `bson:"updated_by,omitempty" json:"updated_by,omitempty"` +} + +// PolicyPatch payload for updating AccessPolicy. +type PolicyPatch struct { + RegistrationMode *string `json:"registration_mode,omitempty"` + EnforceMembership *bool `json:"enforce_membership,omitempty"` + LockMessage *string `json:"lock_message,omitempty"` + RequiredChats *[]RequiredChat `json:"required_chats,omitempty"` +} + +// InviteCode represents a registration invite code. +type InviteCode struct { + Code string `bson:"_id" json:"code"` + MaxUses int `bson:"max_uses" json:"max_uses"` + UsedCount int `bson:"used_count" json:"used_count"` + CreatedBy int64 `bson:"created_by" json:"created_by"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + ExpiresAt *float64 `bson:"expires_at,omitempty" json:"expires_at,omitempty"` + Note string `bson:"note,omitempty" json:"note,omitempty"` + Revoked bool `bson:"revoked" json:"revoked"` +} + +// InviteCreateRequest payload for POST /admin/access/invites. +type InviteCreateRequest struct { + MaxUses int `json:"max_uses"` + TTLDays int `json:"ttl_days"` + Note string `json:"note"` +} + +// AllowlistEntry represents a whitelisted user in MongoDB allowlist. +type AllowlistEntry struct { + UserID int64 `bson:"_id" json:"user_id"` + AddedBy int64 `bson:"added_by" json:"added_by"` + AddedAt float64 `bson:"added_at" json:"added_at"` + Note string `bson:"note,omitempty" json:"note,omitempty"` +} + +// AllowlistRequest payload for POST /admin/access/allowlist and /bypass. +type AllowlistRequest struct { + UserID int64 `json:"user_id"` + Note string `json:"note"` +} + +// LockUserRequest payload for POST /admin/access/users/{id}/lock. +type LockUserRequest struct { + Reason string `json:"reason"` + RevokeSessions bool `json:"revoke_sessions"` +} diff --git a/internal/models/artist_album.go b/internal/models/artist_album.go index 6522649..ac9436c 100644 --- a/internal/models/artist_album.go +++ b/internal/models/artist_album.go @@ -3,57 +3,72 @@ package models // Artist represents an artist record in the artists collection. type Artist struct { ID string `bson:"_id" json:"_id"` + Id string `bson:"-" json:"id,omitempty"` Name string `bson:"name" json:"name"` - MatchArtist string `bson:"match_artist" json:"match_artist"` + MatchArtist string `bson:"match_artist,omitempty" json:"-"` TracksCount int64 `bson:"tracks_count" json:"tracks_count"` - CoverURL string `bson:"cover_url" json:"cover_url"` - Followers int64 `bson:"followers,omitempty" json:"followers"` - CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` - UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` + TrackCount int64 `bson:"-" json:"track_count,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + AvatarURL string `bson:"-" json:"avatar_url,omitempty"` + Followers int64 `bson:"followers,omitempty" json:"followers,omitempty"` + MonthlyListeners int64 `bson:"monthly_listeners,omitempty" json:"monthly_listeners,omitempty"` + Bio string `bson:"bio,omitempty" json:"bio,omitempty"` + IsFollowing bool `bson:"-" json:"is_following"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` } // Album represents an album record in the albums collection. type Album struct { ID string `bson:"_id" json:"_id"` + Id string `bson:"-" json:"id,omitempty"` Title string `bson:"title" json:"title"` - Artist string `bson:"artist" json:"artist"` + Artist string `bson:"artist,omitempty" json:"artist,omitempty"` Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` - CoverURL string `bson:"cover_url" json:"cover_url"` - DurationTotal float64 `bson:"duration_total,omitempty" json:"duration_total"` - MatchAlbum string `bson:"match_album,omitempty" json:"match_album"` - MatchArtist string `bson:"match_artist,omitempty" json:"match_artist"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + DurationTotal float64 `bson:"duration_total,omitempty" json:"duration_total,omitempty"` + MatchAlbum string `bson:"match_album,omitempty" json:"-"` + MatchArtist string `bson:"match_artist,omitempty" json:"-"` TracksCount int64 `bson:"tracks_count" json:"tracks_count"` - CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` - UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` + TrackCount int64 `bson:"-" json:"track_count,omitempty"` + Year int `bson:"year,omitempty" json:"year,omitempty"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` } // ArtistDetail contains full artist information along with their top tracks and albums. type ArtistDetail struct { - Artist *Artist `json:"artist"` - TopTracks []*BrowseItem `json:"top_tracks"` - Albums []*Album `json:"albums"` + Ok bool `json:"ok"` + Artist *Artist `json:"artist"` + PopularTracks []*BrowseItem `json:"popular_tracks"` + TopTracks []*BrowseItem `json:"top_tracks"` + Releases []*Album `json:"releases"` + Albums []*Album `json:"albums"` + Singles []*BrowseItem `json:"singles"` + Tracks []*BrowseItem `json:"tracks"` } // AlbumDetail contains full album metadata and its ordered tracklist. type AlbumDetail struct { + Ok bool `json:"ok"` Album *Album `json:"album"` Tracks []*BrowseItem `json:"tracks"` } -// ArtistsResponse is a paginated list of artists. +// ArtistsResponse is a paginated list of artists matching Python schema. type ArtistsResponse struct { - Items []*Artist `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PerPage int `json:"per_page"` - TotalPages int `json:"total_pages"` + Ok bool `json:"ok"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []*Artist `json:"items"` } -// AlbumsResponse is a paginated list of albums. +// AlbumsResponse is a paginated list of albums matching Python schema. type AlbumsResponse struct { - Items []*Album `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PerPage int `json:"per_page"` - TotalPages int `json:"total_pages"` + Ok bool `json:"ok"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []*Album `json:"items"` } diff --git a/internal/models/auth_user.go b/internal/models/auth_user.go index 5b005f7..eef1b31 100644 --- a/internal/models/auth_user.go +++ b/internal/models/auth_user.go @@ -6,26 +6,102 @@ type TelegramInfo struct { Username string `bson:"username,omitempty" json:"username,omitempty"` } +// PasswordHash stores PBKDF2 password parameters matching StreamXBot. +type PasswordHash struct { + Algo string `bson:"algo" json:"algo"` + Salt string `bson:"salt" json:"salt"` + Iterations int `bson:"iterations" json:"iterations"` + Hash string `bson:"hash" json:"hash"` +} + +// DiscordIntegrationSchema represents Discord Rich Presence / RPC configuration. +type DiscordIntegrationSchema struct { + Enabled bool `bson:"enabled" json:"enabled"` + Token string `bson:"token" json:"token"` + Mode string `bson:"mode,omitempty" json:"mode,omitempty"` + ClientID string `bson:"client_id,omitempty" json:"client_id,omitempty"` + DaemonURL string `bson:"daemon_url,omitempty" json:"daemon_url,omitempty"` + ShowArtwork bool `bson:"show_artwork" json:"show_artwork"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// LastfmIntegrationSchema represents Last.fm scrobbler integration configuration. +type LastfmIntegrationSchema struct { + Enabled bool `bson:"enabled" json:"enabled"` + APIKey string `bson:"api_key" json:"api_key"` + APISecret string `bson:"api_secret" json:"api_secret"` + SessionKey string `bson:"session_key" json:"session_key"` + Username string `bson:"username" json:"username"` + ScrobbleAt float64 `bson:"scrobble_at" json:"scrobble_at"` + NowPlaying bool `bson:"now_playing" json:"now_playing"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// UserIntegrations aggregates all third-party integrations for a user. +type UserIntegrations struct { + Discord *DiscordIntegrationSchema `bson:"discord,omitempty" json:"discord,omitempty"` + Lastfm *LastfmIntegrationSchema `bson:"lastfm,omitempty" json:"lastfm,omitempty"` +} + +// IntegrationsUpdateRequest payload for updating Discord and Lastfm integrations. +type IntegrationsUpdateRequest struct { + Discord *DiscordIntegrationSchema `json:"discord,omitempty"` + Lastfm *LastfmIntegrationSchema `json:"lastfm,omitempty"` +} + // User represents a user record in the users collection. type User struct { - ID int64 `bson:"_id" json:"id"` - UserID int64 `bson:"user_id,omitempty" json:"user_id,omitempty"` - FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` - Username string `bson:"username,omitempty" json:"username,omitempty"` - PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` - ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` - Status string `bson:"status,omitempty" json:"status,omitempty"` - Telegram *TelegramInfo `bson:"telegram,omitempty" json:"telegram,omitempty"` - CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` - UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` + ID int64 `bson:"_id" json:"id"` + UserID int64 `bson:"user_id,omitempty" json:"user_id,omitempty"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` + Password *PasswordHash `bson:"password,omitempty" json:"-"` + TokenVersion int `bson:"token_version,omitempty" json:"token_version,omitempty"` + Telegram *TelegramInfo `bson:"telegram,omitempty" json:"telegram,omitempty"` + Integrations *UserIntegrations `bson:"integrations,omitempty" json:"integrations,omitempty"` + RegisteredVia any `bson:"registered_via,omitempty" json:"registered_via,omitempty"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// RegistrationOTP stores a pending user registration and OTP code. +type RegistrationOTP struct { + ID int64 `bson:"_id" json:"user_id"` + OTP string `bson:"otp" json:"otp"` + Username string `bson:"username" json:"username"` + Password *PasswordHash `bson:"password" json:"password"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + TelegramUsername string `bson:"telegram_username,omitempty" json:"telegram_username,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` } +// OIDCSession stores a pending Telegram OAuth / OpenID Connect authorization session. +type OIDCSession struct { + ID string `bson:"_id" json:"state"` + CodeVerifier string `bson:"code_verifier" json:"code_verifier"` + RedirectURI string `bson:"redirect_uri" json:"redirect_uri"` + FrontendBase string `bson:"frontend_base" json:"frontend_base"` + DestPath string `bson:"dest_path" json:"dest_path"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` +} // AuthResponse returns token and user payload upon successful authentication. type AuthResponse struct { - OK bool `json:"ok"` - Token string `json:"token"` - User *User `json:"user"` + OK bool `json:"ok"` + Token string `json:"token"` + UserID int64 `json:"user_id,omitempty"` + FirstName string `json:"first_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + User *User `json:"user,omitempty"` } // TelegramWebAppRequest contains raw initData string sent from Telegram WebApp. @@ -33,13 +109,118 @@ type TelegramWebAppRequest struct { InitData string `json:"init_data"` } +// TgLoginRequest contains initData and optional credentials for /auth/tg/login. +type TgLoginRequest struct { + InitData string `json:"init_data"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + InviteCode string `json:"invite_code,omitempty"` +} + +// PasswordLoginRequest contains username and password for /auth/login. +type PasswordLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// RegisterRequest contains registration fields for /auth/register matching Python StreamXBot. +type RegisterRequest struct { + UserID int64 `json:"userid"` + Username string `json:"username"` + Password string `json:"password"` + InviteCode string `json:"invite_code,omitempty"` +} + +// TokenClaims contains verified information from an auth token. +type TokenClaims struct { + UserID int64 `json:"user_id"` + IsGuest bool `json:"is_guest"` + FirstName string `json:"first_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + TokenVersion int `json:"token_version,omitempty"` + Exp int64 `json:"exp,omitempty"` +} + +// SetupStatusResponse represents response for GET /auth/setup/status. +type SetupStatusResponse struct { + OK bool `json:"ok"` + Configured bool `json:"configured"` + NeedsSetup bool `json:"needs_setup"` + OwnerID *int64 `json:"owner_id,omitempty"` +} + +// SetupPasswordRequest represents payload for POST /auth/setup, POST /auth/password, POST /auth/password/change. +type SetupPasswordRequest struct { + Password string `json:"password"` +} + +// Password login / change request aliases matching Api/schemas/auth.py. +type OwnerPasswordLoginRequest = SetupPasswordRequest +type SetOwnerPasswordRequest = SetupPasswordRequest +type ChangeOwnerPasswordRequest = SetupPasswordRequest + +// ValidateOTPRequest contains verification fields for /auth/validate. +type ValidateOTPRequest struct { + UserID int64 `json:"userid"` + OTP string `json:"otp"` +} + +// TelegramTokenLoginRequest contains token validation payload for /auth/telegram/validate-token. +type TelegramTokenLoginRequest struct { + IDToken string `json:"id_token"` + Token string `json:"token,omitempty"` + InviteCode string `json:"invite_code,omitempty"` +} + +// SetCookieRequest contains the token to store in an HTTP-only cookie. +type SetCookieRequest struct { + Token string `json:"token"` +} + +// SetCredentialsRequest contains updated username and password for the active user. +type SetCredentialsRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + // TelegramWidgetRequest contains callback parameters from Telegram Login Widget. type TelegramWidgetRequest struct { - ID int64 `json:"id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name,omitempty"` - Username string `json:"username,omitempty"` - PhotoURL string `json:"photo_url,omitempty"` - AuthDate int64 `json:"auth_date"` - Hash string `json:"hash"` + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + AuthDate int64 `json:"auth_date"` + Hash string `json:"hash"` + InviteCode string `json:"invite_code,omitempty"` +} + +// TelegramWidgetLoginRequest alias matching Api/schemas/auth.py. +type TelegramWidgetLoginRequest = TelegramWidgetRequest + +// FCMTokenRequest payload for POST /auth/fcm-token matching Api/schemas/auth.py. +type FCMTokenRequest struct { + FCMToken string `json:"fcm_token"` +} + +// TelegramBotSessionRequest payload for POST /auth/telegram/bot-session matching Api/schemas/auth.py. +type TelegramBotSessionRequest struct { + InviteCode string `json:"invite_code,omitempty"` +} + +// BotAuthSession represents a temporary session for Telegram bot login. +type BotAuthSession struct { + ID string `bson:"_id" json:"session_id"` + Status string `bson:"status" json:"status"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + Token string `bson:"token,omitempty" json:"token,omitempty"` + UserID int64 `bson:"user_id,omitempty" json:"user_id,omitempty"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + ExpiresAt float64 `bson:"expires_at" json:"expires_at"` } diff --git a/internal/models/daily_playlist.go b/internal/models/daily_playlist.go new file mode 100644 index 0000000..e480ea8 --- /dev/null +++ b/internal/models/daily_playlist.go @@ -0,0 +1,30 @@ +package models + +// AvailablePlaylistItem metadata for an available daily playlist. +type AvailablePlaylistItem struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Thumbnails []string `json:"thumbnails"` + ThumbnailURL string `json:"thumbnail_url"` + NormalThumbnail string `json:"normal_thumbnail"` + Endpoint string `json:"endpoint"` + RequiresAuth bool `json:"requires_auth"` +} + +// AvailablePlaylistsResponse response for GET /playlists/available. +type AvailablePlaylistsResponse struct { + Items []*AvailablePlaylistItem `json:"items"` +} + +// DailyPlaylistDoc represents a generated daily playlist in MongoDB. +type DailyPlaylistDoc struct { + ID string `bson:"_id" json:"id"` // key:date:channel_id + Key string `bson:"key" json:"key"` + Date string `bson:"date" json:"date"` + ChannelID int64 `bson:"channel_id,omitempty" json:"channel_id,omitempty"` + TrackIDs []string `bson:"track_ids" json:"track_ids"` + ThumbnailURL string `bson:"thumbnail_url,omitempty" json:"thumbnail_url,omitempty"` + NormalThumbnail string `bson:"normal_thumbnail,omitempty" json:"normal_thumbnail,omitempty"` + GeneratedAt float64 `bson:"generated_at" json:"generated_at"` +} diff --git a/internal/models/favourite_playlist.go b/internal/models/favourite_playlist.go index 55eb05f..f73e022 100644 --- a/internal/models/favourite_playlist.go +++ b/internal/models/favourite_playlist.go @@ -1,11 +1,115 @@ package models -// UserFavourite represents a track liked by a user in the userFavourites collection. +// UserPlaylist represents a custom playlist in the user_playlists collection. +type UserPlaylist struct { + ID string `bson:"_id" json:"playlist_id"` + UserID int64 `bson:"user_id" json:"user_id"` + Name string `bson:"name" json:"name"` + CoverID string `bson:"cover_id,omitempty" json:"cover_id,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + NormalThumbnail string `bson:"normal_thumbnail,omitempty" json:"normal_thumbnail,omitempty"` + CollageHash string `bson:"collage_hash,omitempty" json:"collage_hash,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + UpdatedAt float64 `bson:"updated_at" json:"updated_at"` +} + +// PlaylistTrack represents a track entry in the playlist_tracks collection. +type PlaylistTrack struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + PlaylistID string `bson:"playlist_id" json:"playlist_id"` + TrackID string `bson:"track_id" json:"track_id"` + Position int `bson:"position" json:"position"` + AddedAt float64 `bson:"added_at" json:"added_at"` +} + +// PlaylistItem represents playlist data returned in lists. +type PlaylistItem struct { + PlaylistID string `json:"playlist_id"` + Name string `json:"name"` + Thumbnails []string `json:"thumbnails"` + CoverURL string `json:"cover_url,omitempty"` + NormalThumbnail string `json:"normal_thumbnail,omitempty"` + CoverID string `json:"cover_id,omitempty"` + CreatedAt float64 `json:"created_at,omitempty"` + UpdatedAt float64 `json:"updated_at,omitempty"` +} + +// PlaylistCreate request payload for creating a new playlist. +type PlaylistCreate struct { + Name string `json:"name"` +} + +// PlaylistRename request payload for renaming a playlist. +type PlaylistRename struct { + Name string `json:"name"` +} + +// PlaylistTrackAdd request payload for adding tracks to a playlist. +type PlaylistTrackAdd struct { + TrackID any `json:"track_id,omitempty"` + TrackIDs []string `json:"track_ids,omitempty"` +} + +// PlaylistsResponse is the list of user playlists. +type PlaylistsResponse struct { + Items []PlaylistItem `json:"items"` +} + +// PlaylistTracksResponse contains paginated tracks for a playlist. +type PlaylistTracksResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []*BrowseItem `json:"items"` +} + +// PlaylistShareResponse returns public read-only shared playlist details. +type PlaylistShareResponse struct { + PlaylistID string `json:"playlist_id"` + Name string `json:"name"` + CoverURL string `json:"cover_url,omitempty"` + NormalThumbnail string `json:"normal_thumbnail,omitempty"` + Tracks []*BrowseItem `json:"tracks"` + CreatedAt float64 `json:"created_at,omitempty"` +} + +// UserAlbum represents a saved album in user_albums collection. +type UserAlbum struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + AlbumID string `bson:"album_id" json:"album_id"` + SavedAt float64 `bson:"saved_at" json:"saved_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// UserAlbumAdd payload for saving an album. +type UserAlbumAdd struct { + AlbumID any `json:"album_id,omitempty"` + AlbumIDs []string `json:"album_ids,omitempty"` +} + +// UserAlbumItem represents a single album entry in list saved albums. +type UserAlbumItem struct { + AlbumID string `json:"album_id"` + Album any `json:"album,omitempty"` + SavedAt float64 `json:"saved_at,omitempty"` +} + +// UserAlbumsResponse list of saved albums. +type UserAlbumsResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []UserAlbumItem `json:"items"` +} + +// UserFavourite represents a track liked by a user in user_favourites. type UserFavourite struct { ID string `bson:"_id,omitempty" json:"_id,omitempty"` UserID int64 `bson:"user_id" json:"user_id"` TrackID string `bson:"track_id" json:"track_id"` CreatedAt float64 `bson:"created_at" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` } // UserFavouriteArtist represents a followed artist in user_favourite_artists. @@ -14,57 +118,58 @@ type UserFavouriteArtist struct { UserID int64 `bson:"user_id" json:"user_id"` ArtistID string `bson:"artist_id" json:"artist_id"` CreatedAt float64 `bson:"created_at" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` } -// UserPlaylist represents a custom playlist in the userPlaylists collection. -type UserPlaylist struct { - ID string `bson:"_id" json:"_id"` - UserID int64 `bson:"user_id" json:"user_id"` - Title string `bson:"title" json:"title"` - Description string `bson:"description,omitempty" json:"description"` - CoverURL string `bson:"cover_url,omitempty" json:"cover_url"` - TrackCount int64 `bson:"track_count,omitempty" json:"track_count"` - CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` - UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` +// FavouriteCreate payload for liking a track. +type FavouriteCreate struct { + TrackID string `json:"track_id"` } -// PlaylistTrack represents a track association in the playlistTracks collection. -type PlaylistTrack struct { - ID string `bson:"_id" json:"_id"` - PlaylistID string `bson:"playlist_id" json:"playlist_id"` - TrackID string `bson:"track_id" json:"track_id"` - Position int `bson:"position" json:"position"` - AddedAt float64 `bson:"added_at" json:"added_at"` +// ArtistFavouriteCreate payload for liking an artist. +type ArtistFavouriteCreate struct { + ArtistID string `json:"artist_id,omitempty"` + ID string `json:"id,omitempty"` + ArtistId string `json:"artistId,omitempty"` } -// PlaylistDetail bundles a playlist with its full track entities. -type PlaylistDetail struct { - Playlist *UserPlaylist `json:"playlist"` - Tracks []*BrowseItem `json:"tracks"` +// FavouriteItem pairs a track with the favourited timestamp. +type FavouriteItem struct { + Track *BrowseItem `json:"track"` + CreatedAt float64 `json:"created_at,omitempty"` } -// PlaylistsResponse is the list of user playlists. -type PlaylistsResponse struct { - OK bool `json:"ok"` - Total int `json:"total"` - Items []*UserPlaylist `json:"items"` +// FavouritesResponse contains paginated favourited track items. +type FavouritesResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []FavouriteItem `json:"items"` + LastUpdatedAt *float64 `json:"last_updated_at,omitempty"` } -// FavouriteIDsResponse contains array of liked track IDs. -type FavouriteIDsResponse struct { - OK bool `json:"ok"` - IDs []string `json:"ids"` +// FavouriteIdsResponse contains array of favourited track IDs. +type FavouriteIdsResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + IDs []string `json:"ids"` + Exists bool `json:"exists"` + LastUpdatedAt *float64 `json:"last_updated_at,omitempty"` } +// FavouriteIDsResponse alias for FavouriteIdsResponse. +type FavouriteIDsResponse = FavouriteIdsResponse + // FavouriteArtistsResponse contains array of followed artist IDs. type FavouriteArtistsResponse struct { OK bool `json:"ok"` IDs []string `json:"ids"` } -// ListeningEventItem represents a single telemetry playback event. +// ListeningEventItem represents a single telemetry playback event in listening_events. type ListeningEventItem struct { - ID string `json:"id" bson:"id"` + ID string `json:"id" bson:"event_id"` TrackID string `json:"track_id" bson:"track_id"` PlayedAt float64 `json:"played_at" bson:"played_at"` StartedAt float64 `json:"started_at" bson:"started_at"` @@ -81,3 +186,28 @@ type ListeningEventsPayload struct { Events []ListeningEventItem `json:"events"` } +// StartRemoteAuthResponse starts Discord Remote Auth session. +type StartRemoteAuthResponse struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// RemoteAuthStatusResponse returns status of Discord Remote Auth session. +type RemoteAuthStatusResponse struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + User any `json:"user,omitempty"` + Token string `json:"token,omitempty"` + Error string `json:"error,omitempty"` +} + +// ExternalAssetsRequest payload for proxying Discord asset requests. +type ExternalAssetsRequest struct { + URLs []string `json:"urls"` + ApplicationID string `json:"application_id,omitempty"` + Token string `json:"token"` +} diff --git a/internal/models/friends.go b/internal/models/friends.go new file mode 100644 index 0000000..9bd2c53 --- /dev/null +++ b/internal/models/friends.go @@ -0,0 +1,28 @@ +package models + +// FriendRequestPayload represents payload to send a friend request matching Api/schemas/friends.py. +type FriendRequestPayload struct { + To int64 `json:"to"` +} + +// AcceptRequestPayload represents payload to accept a friend request matching Api/schemas/friends.py. +type AcceptRequestPayload struct { + UserID int64 `json:"userId"` +} + +// InviteJamPayload represents payload to invite a friend to a jam session matching Api/schemas/friends.py. +type InviteJamPayload struct { + ToUserID int64 `json:"toUserId"` + JamID string `json:"jamId"` +} + +// SettingsPayload represents friend / jam privacy settings payload matching Api/schemas/friends.py. +type SettingsPayload struct { + ShareListening *string `json:"share_listening,omitempty"` + AllowJamInvites *bool `json:"allow_jam_invites,omitempty"` +} + +// FcmTokenPayload represents FCM token update payload matching Api/schemas/friends.py. +type FcmTokenPayload struct { + Token string `json:"token"` +} diff --git a/internal/models/history.go b/internal/models/history.go new file mode 100644 index 0000000..f600413 --- /dev/null +++ b/internal/models/history.go @@ -0,0 +1,20 @@ +package models + +// UserHistoryRecord represents a playback record in MongoDB userHistory. +type UserHistoryRecord struct { + ID string `bson:"_id,omitempty" json:"id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + TrackID string `bson:"track_id" json:"track_id"` + PlayedAt float64 `bson:"played_at" json:"played_at"` +} + +// UserPlaybackRecord represents an aggregated record in userPlayback. +type UserPlaybackRecord struct { + ID string `bson:"_id" json:"id"` // user_id:track_id:bucket + UserID int64 `bson:"user_id" json:"user_id"` + TrackID string `bson:"track_id" json:"track_id"` + PlayedAt float64 `bson:"played_at" json:"played_at"` + Bucket int64 `bson:"bucket" json:"bucket"` + Source string `bson:"source,omitempty" json:"source,omitempty"` + Plays int64 `bson:"plays,omitempty" json:"plays,omitempty"` +} diff --git a/internal/models/track.go b/internal/models/track.go index fa3d3f7..71a7a47 100644 --- a/internal/models/track.go +++ b/internal/models/track.go @@ -21,7 +21,7 @@ type AudioMeta struct { Performer string `bson:"performer,omitempty" json:"performer,omitempty"` CoverURL string `bson:"-" json:"cover_url,omitempty"` Lyrics string `bson:"-" json:"lyrics,omitempty"` - Titles map[string]any `bson:"-" json:"titles,omitempty"` + Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` MimeType string `bson:"-" json:"mime_type,omitempty"` FileSize int64 `bson:"-" json:"file_size,omitempty"` } @@ -106,7 +106,8 @@ func (t *Track) EffectiveTitles() map[string]any { // BrowseItem represents a flattened track item optimized for list/feed UI views. type BrowseItem struct { - ID string `json:"_id"` + ID string `json:"id"` + DocID string `json:"_id,omitempty"` SourceChatID int64 `json:"source_chat_id,omitempty"` SourceMessageID int32 `json:"source_message_id,omitempty"` TopicID int32 `json:"topic_id,omitempty"` @@ -132,6 +133,7 @@ type BrowseItem struct { func (t *Track) ToBrowseItem() *BrowseItem { return &BrowseItem{ ID: t.ID, + DocID: t.ID, SourceChatID: t.SourceChatID, SourceMessageID: t.SourceMessageID, TopicID: t.TopicID, @@ -154,28 +156,54 @@ func (t *Track) ToBrowseItem() *BrowseItem { } } -// BrowseResponse represents a paginated response of BrowseItem objects. +// BrowseResponse represents a paginated response of BrowseItem objects matching Api/schemas/browse.py. type BrowseResponse struct { Items []*BrowseItem `json:"items"` Total int64 `json:"total"` Page int `json:"page"` PerPage int `json:"per_page"` - TotalPages int `json:"total_pages"` + TotalPages int `json:"total_pages,omitempty"` + CoverURL string `json:"cover_url,omitempty"` } -// TopicItem represents an aggregated topic with track counts and cover image. +// TopicItem represents an aggregated topic with track counts and cover image matching Api/schemas/topics.py. type TopicItem struct { - TopicID int64 `json:"topic_id"` - TopicName string `json:"topic_name"` - TracksCount int64 `json:"tracks_count"` - CoverURL string `json:"cover_url,omitempty"` + Name string `json:"name"` + TopicName string `json:"topic_name"` + TopicID *int64 `json:"topic_id,omitempty"` + Count int64 `json:"count"` + TracksCount int64 `json:"tracks_count"` + CoverURL string `json:"cover_url,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + NormalThumbnail string `json:"normal_thumbnail,omitempty"` + Thumbnails []string `json:"thumbnails"` + SourceChatID *int64 `json:"source_chat_id,omitempty"` + Endpoint string `json:"endpoint"` } -// TopicsResponse is the response payload for GET /topics. +// TopicsResponse is the response payload for GET /topics matching Api/schemas/topics.py. type TopicsResponse struct { - OK bool `json:"ok"` - Total int `json:"total"` - Items []*TopicItem `json:"items"` + OK bool `json:"ok"` + Total int `json:"total"` + Items []*TopicItem `json:"items"` + Topics []string `json:"topics"` +} + +// TrackResponse represents a full track document matching Api/schemas/track.py. +type TrackResponse struct { + ID string `json:"_id"` + DocID string `json:"id,omitempty"` + SourceChatID *int64 `json:"source_chat_id,omitempty"` + SourceMessageID *int32 `json:"source_message_id,omitempty"` + Telegram map[string]any `json:"telegram,omitempty"` + Audio map[string]any `json:"audio,omitempty"` + Spotify map[string]any `json:"spotify,omitempty"` + Titles map[string]any `json:"titles,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + CreatedAt float64 `json:"created_at,omitempty"` + UpdatedAt float64 `json:"updated_at,omitempty"` + Liked bool `json:"liked"` } // ChannelItem represents an indexed Telegram source channel. diff --git a/internal/repository/access_control_repo.go b/internal/repository/access_control_repo.go new file mode 100644 index 0000000..08dcc7d --- /dev/null +++ b/internal/repository/access_control_repo.go @@ -0,0 +1,300 @@ +package repository + +import ( + "context" + "errors" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// AccessControlRepository manages access control policies, invites, and allowlists. +type AccessControlRepository interface { + GetPolicy(ctx context.Context) (*models.AccessPolicy, error) + SavePolicy(ctx context.Context, policy *models.AccessPolicy) error + + CreateInvite(ctx context.Context, invite *models.InviteCode) error + GetInvite(ctx context.Context, code string) (*models.InviteCode, error) + ConsumeInvite(ctx context.Context, code string) error + RevokeInvite(ctx context.Context, code string) bool + ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) + + AddToAllowlist(ctx context.Context, entry *models.AllowlistEntry) error + RemoveFromAllowlist(ctx context.Context, userID int64) bool + IsInAllowlist(ctx context.Context, userID int64) bool + ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) + + AddToBypass(ctx context.Context, entry *models.AllowlistEntry) error + RemoveFromBypass(ctx context.Context, userID int64) bool + IsInBypass(ctx context.Context, userID int64) bool + ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) + + ListUsers(ctx context.Context, query string, status string, limit int, skip int) ([]*models.User, int64, error) + SetUserStatus(ctx context.Context, userID int64, status string, reason string) error + RevokeUserSessions(ctx context.Context, userID int64) (int, error) +} + +type mongoAccessControlRepository struct { + authConfigCol *mongo.Collection + invitesCol *mongo.Collection + allowlistCol *mongo.Collection + bypassCol *mongo.Collection + usersCol *mongo.Collection +} + +// NewAccessControlRepository creates a MongoDB AccessControlRepository. +func NewAccessControlRepository(db *database.Client) AccessControlRepository { + return &mongoAccessControlRepository{ + authConfigCol: db.Collection("auth_config"), + invitesCol: db.Collection("invites"), + allowlistCol: db.Collection("allowlist"), + bypassCol: db.Collection("bypass"), + usersCol: db.Collection("users"), + } +} + +// GetPolicy retrieves the access policy document, or returns default open policy if not found. +func (r *mongoAccessControlRepository) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { + filter := bson.M{"_id": "access_policy"} + var doc struct { + Policy models.AccessPolicy `bson:"policy"` + } + err := r.authConfigCol.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return &models.AccessPolicy{ + RegistrationMode: "open", + EnforceMembership: false, + RequiredChats: []models.RequiredChat{}, + }, nil + } + return nil, err + } + if doc.Policy.RegistrationMode == "" { + doc.Policy.RegistrationMode = "open" + } + return &doc.Policy, nil +} + +// SavePolicy saves the access policy. +func (r *mongoAccessControlRepository) SavePolicy(ctx context.Context, policy *models.AccessPolicy) error { + filter := bson.M{"_id": "access_policy"} + update := bson.M{ + "$set": bson.M{ + "policy": policy, + "updated_at": float64(time.Now().Unix()), + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.authConfigCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// CreateInvite inserts a new invite code. +func (r *mongoAccessControlRepository) CreateInvite(ctx context.Context, invite *models.InviteCode) error { + _, err := r.invitesCol.InsertOne(ctx, invite) + return err +} + +// GetInvite finds an invite code. +func (r *mongoAccessControlRepository) GetInvite(ctx context.Context, code string) (*models.InviteCode, error) { + filter := bson.M{"_id": code} + var inv models.InviteCode + err := r.invitesCol.FindOne(ctx, filter).Decode(&inv) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &inv, nil +} + +// ConsumeInvite increments the used count on an invite code. +func (r *mongoAccessControlRepository) ConsumeInvite(ctx context.Context, code string) error { + filter := bson.M{"_id": code} + update := bson.M{"$inc": bson.M{"used_count": 1}} + _, err := r.invitesCol.UpdateOne(ctx, filter, update) + return err +} + +// RevokeInvite marks an invite as revoked. +func (r *mongoAccessControlRepository) RevokeInvite(ctx context.Context, code string) bool { + res, err := r.invitesCol.UpdateOne(ctx, bson.M{"_id": code}, bson.M{"$set": bson.M{"revoked": true}}) + return err == nil && res.MatchedCount > 0 +} + +// ListInvites lists invite codes. +func (r *mongoAccessControlRepository) ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) { + filter := bson.M{} + if !includeDead { + now := float64(time.Now().Unix()) + filter["revoked"] = bson.M{"$ne": true} + filter["$or"] = []bson.M{ + {"expires_at": nil}, + {"expires_at": bson.M{"$gt": now}}, + } + } + + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetLimit(100) + cursor, err := r.invitesCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.InviteCode + for cursor.Next(ctx) { + var inv models.InviteCode + if err := cursor.Decode(&inv); err == nil { + items = append(items, &inv) + } + } + return items, nil +} + +// AddToAllowlist adds a user to allowlist. +func (r *mongoAccessControlRepository) AddToAllowlist(ctx context.Context, entry *models.AllowlistEntry) error { + opts := options.UpdateOne().SetUpsert(true) + _, err := r.allowlistCol.UpdateOne(ctx, bson.M{"_id": entry.UserID}, bson.M{"$set": entry}, opts) + return err +} + +// RemoveFromAllowlist removes user from allowlist. +func (r *mongoAccessControlRepository) RemoveFromAllowlist(ctx context.Context, userID int64) bool { + res, err := r.allowlistCol.DeleteOne(ctx, bson.M{"_id": userID}) + return err == nil && res.DeletedCount > 0 +} + +// IsInAllowlist checks if a user is in allowlist. +func (r *mongoAccessControlRepository) IsInAllowlist(ctx context.Context, userID int64) bool { + err := r.allowlistCol.FindOne(ctx, bson.M{"_id": userID}).Err() + return err == nil +} + +// ListAllowlist lists all allowlisted users. +func (r *mongoAccessControlRepository) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { + cursor, err := r.allowlistCol.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "added_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.AllowlistEntry + for cursor.Next(ctx) { + var e models.AllowlistEntry + if err := cursor.Decode(&e); err == nil { + items = append(items, &e) + } + } + return items, nil +} + +// AddToBypass adds a user to bypass list. +func (r *mongoAccessControlRepository) AddToBypass(ctx context.Context, entry *models.AllowlistEntry) error { + opts := options.UpdateOne().SetUpsert(true) + _, err := r.bypassCol.UpdateOne(ctx, bson.M{"_id": entry.UserID}, bson.M{"$set": entry}, opts) + return err +} + +// RemoveFromBypass removes user from bypass list. +func (r *mongoAccessControlRepository) RemoveFromBypass(ctx context.Context, userID int64) bool { + res, err := r.bypassCol.DeleteOne(ctx, bson.M{"_id": userID}) + return err == nil && res.DeletedCount > 0 +} + +// IsInBypass checks if a user is in the membership bypass list. +func (r *mongoAccessControlRepository) IsInBypass(ctx context.Context, userID int64) bool { + err := r.bypassCol.FindOne(ctx, bson.M{"_id": userID}).Err() + return err == nil +} + +// ListBypass lists all bypassed users. +func (r *mongoAccessControlRepository) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { + cursor, err := r.bypassCol.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "added_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.AllowlistEntry + for cursor.Next(ctx) { + var e models.AllowlistEntry + if err := cursor.Decode(&e); err == nil { + items = append(items, &e) + } + } + return items, nil +} + +// ListUsers lists users with optional search and status filter. +func (r *mongoAccessControlRepository) ListUsers(ctx context.Context, query string, status string, limit int, skip int) ([]*models.User, int64, error) { + filter := bson.M{} + if s := strings.TrimSpace(status); s != "" { + filter["status"] = s + } + if q := strings.TrimSpace(query); q != "" { + filter["$or"] = []bson.M{ + {"username": bson.M{"$regex": q, "$options": "i"}}, + {"first_name": bson.M{"$regex": q, "$options": "i"}}, + } + } + + total, err := r.usersCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetLimit(int64(limit)). + SetSkip(int64(skip)) + + cursor, err := r.usersCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var users []*models.User + for cursor.Next(ctx) { + var u models.User + if err := cursor.Decode(&u); err == nil { + users = append(users, &u) + } + } + return users, total, nil +} + +// SetUserStatus updates user status (active, locked, restricted). +func (r *mongoAccessControlRepository) SetUserStatus(ctx context.Context, userID int64, status string, reason string) error { + update := bson.M{ + "$set": bson.M{ + "status": status, + "updated_at": float64(time.Now().Unix()), + "lock_reason": reason, + }, + } + _, err := r.usersCol.UpdateOne(ctx, bson.M{"_id": userID}, update) + return err +} + +// RevokeUserSessions increments token_version to invalidate all active tokens. +func (r *mongoAccessControlRepository) RevokeUserSessions(ctx context.Context, userID int64) (int, error) { + update := bson.M{ + "$inc": bson.M{"token_version": 1}, + "$set": bson.M{"updated_at": float64(time.Now().Unix())}, + } + var res models.User + err := r.usersCol.FindOneAndUpdate(ctx, bson.M{"_id": userID}, update, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&res) + if err != nil { + return 0, err + } + return res.TokenVersion, nil +} diff --git a/internal/repository/artist_album_repo.go b/internal/repository/artist_album_repo.go index ee1d7f8..f8649c4 100644 --- a/internal/repository/artist_album_repo.go +++ b/internal/repository/artist_album_repo.go @@ -6,6 +6,8 @@ import ( "fmt" "regexp" "strings" + "sync" + "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" @@ -15,22 +17,61 @@ import ( "streamgo/internal/models" ) +var ( + artistSplitRegex = regexp.MustCompile(`(?i)\s*(?:,|/|&|\s+and\s+|\s+x\s+|\s+feat\.\s+|\s+feat\s+|\s+ft\.\s+|\s+ft\s+)\s*`) +) + +func splitArtists(value string) []string { + raw := strings.TrimSpace(value) + if raw == "" { + return []string{} + } + raw = strings.ReplaceAll(raw, "(", " ") + raw = strings.ReplaceAll(raw, ")", " ") + raw = strings.ReplaceAll(raw, "[", " ") + raw = strings.ReplaceAll(raw, "]", " ") + + parts := artistSplitRegex.Split(raw, -1) + out := make([]string, 0, len(parts)) + seen := make(map[string]bool) + + for _, p := range parts { + token := strings.Trim(strings.TrimSpace(p), " .") + if token == "" { + continue + } + key := strings.ToLower(token) + if seen[key] { + continue + } + seen[key] = true + out = append(out, token) + } + return out +} + // ArtistAlbumRepository manages data access for artists and albums. type ArtistAlbumRepository interface { - ListArtists(ctx context.Context, page, perPage int) ([]*models.Artist, int64, error) + ListArtists(ctx context.Context, page, perPage int, refresh bool) ([]*models.Artist, int64, error) GetArtistByID(ctx context.Context, id string) (*models.Artist, error) GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) + GetArtistTracksPaginated(ctx context.Context, artistName string, page, perPage int) ([]*models.Track, int64, error) GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) - ListAlbums(ctx context.Context, page, perPage int, artistFilter string) ([]*models.Album, int64, error) + ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) ([]*models.Album, int64, error) GetAlbumByID(ctx context.Context, id string) (*models.Album, error) GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) + GetAlbumTracksPaginated(ctx context.Context, albumID string, page, perPage int) ([]*models.Track, int64, error) + RefreshAlbumsCache(ctx context.Context, limit int) (int, error) + RefreshArtistsCache(ctx context.Context, limitTracks, limitArtists int) (int, error) } type mongoArtistAlbumRepository struct { - artistsCol *mongo.Collection - albumsCol *mongo.Collection - tracksCol *mongo.Collection + artistsCol *mongo.Collection + albumsCol *mongo.Collection + tracksCol *mongo.Collection + albumRefreshMu sync.Mutex + artistRefreshMu sync.Mutex } // NewArtistAlbumRepository creates an ArtistAlbumRepository backed by MongoDB. @@ -42,15 +83,194 @@ func NewArtistAlbumRepository(db *database.Client) ArtistAlbumRepository { } } -func (r *mongoArtistAlbumRepository) ListArtists(ctx context.Context, page, perPage int) ([]*models.Artist, int64, error) { +func (r *mongoArtistAlbumRepository) RefreshArtistsCache(ctx context.Context, limitTracks, limitArtists int) (int, error) { + r.artistRefreshMu.Lock() + defer r.artistRefreshMu.Unlock() + + if limitTracks <= 0 { + limitTracks = 20000 + } + if limitTracks > 100000 { + limitTracks = 100000 + } + if limitArtists <= 0 { + limitArtists = 5000 + } + if limitArtists > 20000 { + limitArtists = 20000 + } + + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). + SetLimit(int64(limitTracks)). + SetProjection(bson.M{ + "audio.artist": 1, + "audio.performer": 1, + "audio.artists": 1, + "spotify.artist_avatar": 1, + "updated_at": 1, + }) + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.artist": bson.M{"$exists": true, "$ne": ""}}, + {"audio.performer": bson.M{"$exists": true, "$ne": ""}}, + }, + } + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return 0, err + } + defer cursor.Close(ctx) + + type artistEntry struct { + Name string + MatchArtist string + TracksCount int64 + AvatarURL string + UpdatedAt float64 + } + + byKey := make(map[string]*artistEntry) + for cursor.Next(ctx) { + var doc struct { + Audio struct { + Artist string `bson:"artist"` + Performer string `bson:"performer"` + Artists []string `bson:"artists"` + } `bson:"audio"` + Spotify struct { + ArtistAvatar string `bson:"artist_avatar"` + } `bson:"spotify"` + UpdatedAt float64 `bson:"updated_at"` + } + if err := cursor.Decode(&doc); err != nil { + continue + } + + var names []string + if len(doc.Audio.Artists) > 0 { + for _, a := range doc.Audio.Artists { + names = append(names, splitArtists(a)...) + } + } + if len(names) == 0 { + raw := doc.Audio.Artist + if raw == "" { + raw = doc.Audio.Performer + } + if raw != "" { + names = splitArtists(raw) + } + } + + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + k := strings.ToLower(name) + if entry, exists := byKey[k]; exists { + entry.TracksCount++ + if doc.UpdatedAt > entry.UpdatedAt { + entry.UpdatedAt = doc.UpdatedAt + } + if entry.AvatarURL == "" && doc.Spotify.ArtistAvatar != "" { + entry.AvatarURL = doc.Spotify.ArtistAvatar + } + } else { + byKey[k] = &artistEntry{ + Name: name, + MatchArtist: k, + TracksCount: 1, + AvatarURL: doc.Spotify.ArtistAvatar, + UpdatedAt: doc.UpdatedAt, + } + } + } + } + + now := float64(time.Now().Unix()) + upserted := 0 + for _, entry := range byKey { + aid := "artist_" + entry.MatchArtist + updateDoc := bson.M{ + "name": entry.Name, + "match_artist": entry.MatchArtist, + "tracks_count": entry.TracksCount, + "updated_at": entry.UpdatedAt, + } + if entry.AvatarURL != "" { + updateDoc["cover_url"] = entry.AvatarURL + } + if entry.UpdatedAt == 0 { + updateDoc["updated_at"] = now + } + + opts := options.UpdateOne().SetUpsert(true) + _, err := r.artistsCol.UpdateOne(ctx, + bson.M{"_id": aid}, + bson.M{ + "$setOnInsert": bson.M{"created_at": now, "followers": 0}, + "$set": updateDoc, + }, + opts, + ) + if err == nil { + upserted++ + } + if upserted >= limitArtists { + break + } + } + + return upserted, nil +} + +func (r *mongoArtistAlbumRepository) ListArtists(ctx context.Context, page, perPage int, refresh bool) ([]*models.Artist, int64, error) { if page < 1 { page = 1 } - if perPage < 1 || perPage > 100 { - perPage = 20 + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 } - filter := bson.M{"tracks_count": bson.M{"$gt": 0}} + existing, _ := r.artistsCol.EstimatedDocumentCount(ctx) + shouldRefresh := refresh || existing <= 0 + if !shouldRefresh { + var latestTrack struct { + UpdatedAt float64 `bson:"updated_at"` + } + var latestArtist struct { + UpdatedAt float64 `bson:"updated_at"` + } + trackOpts := options.FindOne().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetProjection(bson.M{"updated_at": 1}) + _ = r.tracksCol.FindOne(ctx, bson.M{"deleted": bson.M{"$ne": true}}, trackOpts).Decode(&latestTrack) + + artistOpts := options.FindOne().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetProjection(bson.M{"updated_at": 1}) + _ = r.artistsCol.FindOne(ctx, bson.M{}, artistOpts).Decode(&latestArtist) + + if latestTrack.UpdatedAt > latestArtist.UpdatedAt { + shouldRefresh = true + } + } + + if shouldRefresh { + limitTracks := 20000 + limitArtists := 5000 + if refresh { + limitTracks = 100000 + limitArtists = 20000 + } + _, _ = r.RefreshArtistsCache(ctx, limitTracks, limitArtists) + } + + filter := bson.M{} total, err := r.artistsCol.CountDocuments(ctx, filter) if err != nil { @@ -59,7 +279,7 @@ func (r *mongoArtistAlbumRepository) ListArtists(ctx context.Context, page, perP skip := int64((page - 1) * perPage) opts := options.Find(). - SetSort(bson.D{{Key: "tracks_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). SetSkip(skip). SetLimit(int64(perPage)) @@ -74,6 +294,18 @@ func (r *mongoArtistAlbumRepository) ListArtists(ctx context.Context, page, perP return nil, 0, fmt.Errorf("failed to decode artists: %w", err) } + for _, a := range artists { + if a.Id == "" { + a.Id = a.ID + } + if a.AvatarURL == "" { + a.AvatarURL = a.CoverURL + } + if a.TrackCount == 0 { + a.TrackCount = a.TracksCount + } + } + return artists, total, nil } @@ -90,6 +322,16 @@ func (r *mongoArtistAlbumRepository) GetArtistByID(ctx context.Context, id strin return nil, fmt.Errorf("failed to fetch artist %s: %w", id, err) } + if artist.Id == "" { + artist.Id = artist.ID + } + if artist.AvatarURL == "" { + artist.AvatarURL = artist.CoverURL + } + if artist.TrackCount == 0 { + artist.TrackCount = artist.TracksCount + } + return &artist, nil } @@ -127,6 +369,53 @@ func (r *mongoArtistAlbumRepository) GetArtistTracks(ctx context.Context, artist return tracks, nil } +func (r *mongoArtistAlbumRepository) GetArtistTracksPaginated(ctx context.Context, artistName string, page, perPage int) ([]*models.Track, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + escaped := regexp.QuoteMeta(artistName) + regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.artist": regex}, + {"audio.artists": regex}, + {"audio.performer": regex}, + }, + } + + total, err := r.tracksCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, 0, err + } + return tracks, total, nil +} + func (r *mongoArtistAlbumRepository) GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) { escaped := regexp.QuoteMeta(artistName) regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} @@ -153,15 +442,189 @@ func (r *mongoArtistAlbumRepository) GetArtistAlbums(ctx context.Context, artist if err := cursor.All(ctx, &albums); err != nil { return nil, err } + + for _, a := range albums { + if a.Id == "" { + a.Id = a.ID + } + if a.TrackCount == 0 { + a.TrackCount = a.TracksCount + } + } + return albums, nil } -func (r *mongoArtistAlbumRepository) ListAlbums(ctx context.Context, page, perPage int, artistFilter string) ([]*models.Album, int64, error) { +func (r *mongoArtistAlbumRepository) RefreshAlbumsCache(ctx context.Context, limit int) (int, error) { + r.albumRefreshMu.Lock() + defer r.albumRefreshMu.Unlock() + + if limit <= 0 { + limit = 2000 + } + if limit > 5000 { + limit = 5000 + } + + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": bson.M{"$exists": true, "$ne": ""}, + "$or": []bson.M{ + {"audio.album": bson.M{"$exists": true, "$ne": ""}}, + {"audio.title": bson.M{"$exists": true, "$ne": ""}}, + }, + }}}, + {{Key: "$addFields", Value: bson.M{ + "_aid": "$audio.album_id", + "_album_title": bson.M{"$ifNull": []any{"$audio.album", "$audio.title"}}, + "_album_artist": bson.M{ + "$cond": []any{ + bson.M{"$and": []any{ + bson.M{"$isArray": "$audio.artists"}, + bson.M{"$gt": []any{bson.M{"$size": "$audio.artists"}, 0}}, + }}, + bson.M{"$arrayElemAt": []any{"$audio.artists", 0}}, + bson.M{"$ifNull": []any{"$audio.artist", "$audio.performer"}}, + }, + }, + "_year": "$audio.year", + }}}, + {{Key: "$addFields", Value: bson.M{ + "_album_norm": bson.M{"$toLower": bson.M{"$trim": bson.M{"input": "$_album_title"}}}, + "_artist_norm": bson.M{"$toLower": bson.M{"$trim": bson.M{"input": "$_album_artist"}}}, + }}}, + {{Key: "$sort", Value: bson.D{{Key: "updated_at", Value: -1}}}}, + {{Key: "$group", Value: bson.M{ + "_id": "$_aid", + "title": bson.M{"$first": "$_album_title"}, + "artist": bson.M{"$first": "$_album_artist"}, + "cover_url": bson.M{"$first": bson.M{"$ifNull": []any{"$spotify.big_cover_url", "$spotify.cover_url"}}}, + "year": bson.M{"$first": "$_year"}, + "tracks_count": bson.M{"$sum": 1}, + "duration_total": bson.M{"$sum": bson.M{"$ifNull": []any{"$audio.duration_sec", 0}}}, + "updated_at": bson.M{"$max": "$updated_at"}, + "match_album": bson.M{"$first": "$_album_norm"}, + "match_artist": bson.M{"$first": "$_artist_norm"}, + }}}, + {{Key: "$sort", Value: bson.D{{Key: "updated_at", Value: -1}}}}, + {{Key: "$limit", Value: limit}}, + } + + cursor, err := r.tracksCol.Aggregate(ctx, pipeline) + if err != nil { + return 0, err + } + defer cursor.Close(ctx) + + now := float64(time.Now().Unix()) + upserted := 0 + + for cursor.Next(ctx) { + var row struct { + ID string `bson:"_id"` + Title string `bson:"title"` + Artist string `bson:"artist"` + CoverURL string `bson:"cover_url"` + Year int `bson:"year"` + TracksCount int64 `bson:"tracks_count"` + DurationTotal float64 `bson:"duration_total"` + UpdatedAt float64 `bson:"updated_at"` + MatchAlbum string `bson:"match_album"` + MatchArtist string `bson:"match_artist"` + } + if err := cursor.Decode(&row); err != nil { + continue + } + + aid := strings.TrimSpace(row.ID) + title := strings.TrimSpace(row.Title) + matchAlbum := strings.TrimSpace(row.MatchAlbum) + if aid == "" || title == "" || matchAlbum == "" { + continue + } + + artist := strings.TrimSpace(row.Artist) + var artists []string + if artist != "" { + artists = splitArtists(artist) + } + + updateDoc := bson.M{ + "title": title, + "artist": artist, + "cover_url": row.CoverURL, + "tracks_count": row.TracksCount, + "duration_total": row.DurationTotal, + "match_album": matchAlbum, + "match_artist": strings.TrimSpace(row.MatchArtist), + "updated_at": row.UpdatedAt, + } + if len(artists) > 0 { + updateDoc["artists"] = artists + } + if row.Year > 0 { + updateDoc["year"] = row.Year + } + if row.UpdatedAt == 0 { + updateDoc["updated_at"] = now + } + + opts := options.UpdateOne().SetUpsert(true) + _, err := r.albumsCol.UpdateOne(ctx, + bson.M{"_id": aid}, + bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": updateDoc, + }, + opts, + ) + if err == nil { + upserted++ + } + } + + return upserted, nil +} + +func (r *mongoArtistAlbumRepository) ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) ([]*models.Album, int64, error) { if page < 1 { page = 1 } - if perPage < 1 || perPage > 100 { - perPage = 20 + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + existing, _ := r.albumsCol.EstimatedDocumentCount(ctx) + shouldRefresh := refresh || existing <= 0 + if !shouldRefresh { + var latestTrack struct { + UpdatedAt float64 `bson:"updated_at"` + } + var latestAlbum struct { + UpdatedAt float64 `bson:"updated_at"` + } + trackOpts := options.FindOne().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetProjection(bson.M{"updated_at": 1}) + _ = r.tracksCol.FindOne(ctx, bson.M{"deleted": bson.M{"$ne": true}, "audio.album_id": bson.M{"$exists": true, "$ne": ""}}, trackOpts).Decode(&latestTrack) + + albumOpts := options.FindOne().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetProjection(bson.M{"updated_at": 1}) + _ = r.albumsCol.FindOne(ctx, bson.M{}, albumOpts).Decode(&latestAlbum) + + if latestTrack.UpdatedAt > latestAlbum.UpdatedAt { + shouldRefresh = true + } + } + + if shouldRefresh { + limit := 2000 + if refresh { + limit = 5000 + } + _, _ = r.RefreshAlbumsCache(ctx, limit) + existing, _ = r.albumsCol.EstimatedDocumentCount(ctx) } filter := bson.M{} @@ -170,9 +633,12 @@ func (r *mongoArtistAlbumRepository) ListAlbums(ctx context.Context, page, perPa filter["artist"] = bson.M{"$regex": escaped, "$options": "i"} } - total, err := r.albumsCol.CountDocuments(ctx, filter) - if err != nil { - return nil, 0, err + total := existing + if artistFilter != "" { + cnt, err := r.albumsCol.CountDocuments(ctx, filter) + if err == nil { + total = cnt + } } skip := int64((page - 1) * perPage) @@ -191,6 +657,16 @@ func (r *mongoArtistAlbumRepository) ListAlbums(ctx context.Context, page, perPa if err := cursor.All(ctx, &albums); err != nil { return nil, 0, err } + + for _, a := range albums { + if a.Id == "" { + a.Id = a.ID + } + if a.TrackCount == 0 { + a.TrackCount = a.TracksCount + } + } + return albums, total, nil } @@ -200,10 +676,26 @@ func (r *mongoArtistAlbumRepository) GetAlbumByID(ctx context.Context, id string err := r.albumsCol.FindOne(ctx, bson.M{"_id": id}).Decode(&album) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { - return nil, nil + _, _ = r.RefreshAlbumsCache(ctx, 5000) + err2 := r.albumsCol.FindOne(ctx, bson.M{"_id": id}).Decode(&album) + if err2 != nil { + if errors.Is(err2, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err2 + } + } else { + return nil, err } - return nil, err } + + if album.Id == "" { + album.Id = album.ID + } + if album.TrackCount == 0 { + album.TrackCount = album.TracksCount + } + return &album, nil } @@ -214,8 +706,12 @@ func (r *mongoArtistAlbumRepository) GetAlbumTracks(ctx context.Context, albumID } opts := options.Find(). - SetSort(bson.D{{Key: "updated_at", Value: 1}}). - SetLimit(100) + SetSort(bson.D{ + {Key: "audio.track_number", Value: 1}, + {Key: "source_message_id", Value: 1}, + {Key: "updated_at", Value: -1}, + }). + SetLimit(200) cursor, err := r.tracksCol.Find(ctx, filter, opts) if err != nil { @@ -229,3 +725,47 @@ func (r *mongoArtistAlbumRepository) GetAlbumTracks(ctx context.Context, albumID } return tracks, nil } + +func (r *mongoArtistAlbumRepository) GetAlbumTracksPaginated(ctx context.Context, albumID string, page, perPage int) ([]*models.Track, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": albumID, + } + + total, err := r.tracksCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{ + {Key: "audio.track_number", Value: 1}, + {Key: "source_message_id", Value: 1}, + {Key: "updated_at", Value: -1}, + }). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, 0, err + } + return tracks, total, nil +} diff --git a/internal/repository/daily_playlist_repo.go b/internal/repository/daily_playlist_repo.go new file mode 100644 index 0000000..fb1fba4 --- /dev/null +++ b/internal/repository/daily_playlist_repo.go @@ -0,0 +1,53 @@ +package repository + +import ( + "context" + "errors" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// DailyPlaylistRepository manages generated daily playlist documents. +type DailyPlaylistRepository interface { + GetCachedDailyPlaylist(ctx context.Context, docID string) (*models.DailyPlaylistDoc, error) + SaveDailyPlaylist(ctx context.Context, doc *models.DailyPlaylistDoc) error +} + +type mongoDailyPlaylistRepository struct { + col *mongo.Collection +} + +// NewDailyPlaylistRepository creates a new DailyPlaylistRepository. +func NewDailyPlaylistRepository(db *database.Client) DailyPlaylistRepository { + return &mongoDailyPlaylistRepository{ + col: db.Collection("daily_playlists"), + } +} + +// GetCachedDailyPlaylist retrieves a cached daily playlist by ID. +func (r *mongoDailyPlaylistRepository) GetCachedDailyPlaylist(ctx context.Context, docID string) (*models.DailyPlaylistDoc, error) { + filter := bson.M{"_id": docID} + var doc models.DailyPlaylistDoc + err := r.col.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +// SaveDailyPlaylist upserts a generated daily playlist document. +func (r *mongoDailyPlaylistRepository) SaveDailyPlaylist(ctx context.Context, doc *models.DailyPlaylistDoc) error { + filter := bson.M{"_id": doc.ID} + update := bson.M{"$set": doc} + opts := options.UpdateOne().SetUpsert(true) + _, err := r.col.UpdateOne(ctx, filter, update, opts) + return err +} diff --git a/internal/repository/favourite_playlist_repo.go b/internal/repository/favourite_playlist_repo.go index 76d7f52..37aafa9 100644 --- a/internal/repository/favourite_playlist_repo.go +++ b/internal/repository/favourite_playlist_repo.go @@ -14,13 +14,13 @@ import ( "streamgo/internal/models" ) -// FavouritePlaylistRepository defines data operations for favourites, playlists, and history. +// FavouritePlaylistRepository defines data operations for favourites, playlists, albums, and history. type FavouritePlaylistRepository interface { // Track Favourites AddFavourite(ctx context.Context, userID int64, trackID string) (alreadyExists bool, err error) RemoveFavourite(ctx context.Context, userID int64, trackID string) (deleted bool, err error) GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) - GetFavourites(ctx context.Context, userID int64, page, limit int) ([]*models.UserFavourite, int64, *float64, error) + GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) // Artist Favourites AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (alreadyExists bool, err error) @@ -30,18 +30,23 @@ type FavouritePlaylistRepository interface { // Playlists CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) - GetPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) - UpdatePlaylist(ctx context.Context, playlistID string, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) - DeletePlaylist(ctx context.Context, playlistID string, userID int64) (bool, error) - AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error + GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) + GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) + RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) + DeletePlaylist(ctx context.Context, playlistID string, userID int64) error + AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error - GetPlaylistTrackIDs(ctx context.Context, playlistID string) ([]string, error) - ReorderPlaylistTracks(ctx context.Context, playlistID string, trackIDs []string) error + GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) + GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) + + // Albums + SaveAlbum(ctx context.Context, userID int64, albumID string) (alreadyExists bool, err error) + GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) (albumIDs []string, savedAts []float64, total int64, err error) // History & Listening Events - RecordHistory(ctx context.Context, userID int64, trackID string, playedAt float64) error + RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) - RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error + GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) } type mongoFavouritePlaylistRepository struct { @@ -50,6 +55,7 @@ type mongoFavouritePlaylistRepository struct { artistsCol *mongo.Collection playlistsCol *mongo.Collection playlistTrkCol *mongo.Collection + userAlbumsCol *mongo.Collection historyCol *mongo.Collection eventsCol *mongo.Collection } @@ -57,17 +63,18 @@ type mongoFavouritePlaylistRepository struct { // NewFavouritePlaylistRepository returns an instance of FavouritePlaylistRepository. func NewFavouritePlaylistRepository(db *database.Client) FavouritePlaylistRepository { return &mongoFavouritePlaylistRepository{ - favCol: db.Collection("userFavourites"), + favCol: db.Collection("user_favourites"), favArtistCol: db.Collection("user_favourite_artists"), artistsCol: db.Collection("artists"), - playlistsCol: db.Collection("userPlaylists"), - playlistTrkCol: db.Collection("playlistTracks"), + playlistsCol: db.Collection("user_playlists"), + playlistTrkCol: db.Collection("playlist_tracks"), + userAlbumsCol: db.Collection("user_albums"), historyCol: db.Collection("userHistory"), - eventsCol: db.Collection("user_listening_events"), + eventsCol: db.Collection("listening_events"), } } -// AddFavourite adds a track to user favourites. +// AddFavourite adds a track to user_favourites. func (r *mongoFavouritePlaylistRepository) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { now := float64(time.Now().Unix()) filter := bson.M{"user_id": userID, "track_id": trackID} @@ -84,7 +91,7 @@ func (r *mongoFavouritePlaylistRepository) AddFavourite(ctx context.Context, use return alreadyExists, nil } -// RemoveFavourite deletes a track from user favourites. +// RemoveFavourite deletes a track from user_favourites. func (r *mongoFavouritePlaylistRepository) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { filter := bson.M{"user_id": userID, "track_id": trackID} res, err := r.favCol.DeleteOne(ctx, filter) @@ -99,100 +106,105 @@ func (r *mongoFavouritePlaylistRepository) GetFavouriteTrackIDs(ctx context.Cont if page < 1 { page = 1 } - if limit < 1 || limit > 1000 { - limit = 200 + if limit < 1 { + limit = 20 } + skip := int64((page - 1) * limit) + filter := bson.M{"user_id": userID} total, err := r.favCol.CountDocuments(ctx, filter) if err != nil { return nil, 0, nil, fmt.Errorf("failed to count favourites: %w", err) } - skip := int64((page - 1) * limit) opts := options.Find(). - SetProjection(bson.M{"_id": 0, "track_id": 1, "created_at": 1, "updated_at": 1}). SetSort(bson.D{{Key: "created_at", Value: -1}}). SetSkip(skip). - SetLimit(int64(limit)) + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "created_at": 1, "updated_at": 1, "_id": 0}) - cursor, err := r.favCol.Find(ctx, filter, opts) + cur, err := r.favCol.Find(ctx, filter, opts) if err != nil { - return nil, 0, nil, fmt.Errorf("failed to query favourite track IDs: %w", err) + return nil, 0, nil, fmt.Errorf("failed to query favourites: %w", err) } - defer cursor.Close(ctx) + defer cur.Close(ctx) var ids []string - var maxTS *float64 - for cursor.Next(ctx) { + var lastTS *float64 + + for cur.Next(ctx) { var doc struct { TrackID string `bson:"track_id"` CreatedAt *float64 `bson:"created_at"` UpdatedAt *float64 `bson:"updated_at"` } - if err := cursor.Decode(&doc); err != nil { - continue - } - if doc.TrackID != "" { + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { ids = append(ids, doc.TrackID) - } - ts := doc.UpdatedAt - if ts == nil { - ts = doc.CreatedAt - } - if ts != nil { - if maxTS == nil || *ts > *maxTS { - t := *ts - maxTS = &t + ts := doc.UpdatedAt + if ts == nil { + ts = doc.CreatedAt + } + if ts != nil && (lastTS == nil || *ts > *lastTS) { + lastTS = ts } } } - return ids, total, maxTS, nil + return ids, total, lastTS, nil } -// GetFavourites returns paginated user favourite entries. -func (r *mongoFavouritePlaylistRepository) GetFavourites(ctx context.Context, userID int64, page, limit int) ([]*models.UserFavourite, int64, *float64, error) { +// GetAllFavouriteTrackIDs returns IDs for the /favourites/ids endpoint. +func (r *mongoFavouritePlaylistRepository) GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { if page < 1 { page = 1 } - if limit < 1 || limit > 100 { - limit = 20 + if limit < 1 { + limit = 200 } + skip := int64((page - 1) * limit) + filter := bson.M{"user_id": userID} total, err := r.favCol.CountDocuments(ctx, filter) if err != nil { return nil, 0, nil, err } - skip := int64((page - 1) * limit) opts := options.Find(). SetSort(bson.D{{Key: "created_at", Value: -1}}). SetSkip(skip). - SetLimit(int64(limit)) + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "created_at": 1, "updated_at": 1, "_id": 0}) - cursor, err := r.favCol.Find(ctx, filter, opts) + cur, err := r.favCol.Find(ctx, filter, opts) if err != nil { return nil, 0, nil, err } - defer cursor.Close(ctx) + defer cur.Close(ctx) - var items []*models.UserFavourite - var maxTS *float64 - for cursor.Next(ctx) { - var fav models.UserFavourite - if err := cursor.Decode(&fav); err != nil { - continue + var ids []string + var lastTS *float64 + + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + CreatedAt *float64 `bson:"created_at"` + UpdatedAt *float64 `bson:"updated_at"` } - items = append(items, &fav) - if maxTS == nil || fav.CreatedAt > *maxTS { - t := fav.CreatedAt - maxTS = &t + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + ts := doc.UpdatedAt + if ts == nil { + ts = doc.CreatedAt + } + if ts != nil && (lastTS == nil || *ts > *lastTS) { + lastTS = ts + } } } - return items, total, maxTS, nil + return ids, total, lastTS, nil } -// AddFavouriteArtist adds an artist to user favourites and updates follower count. +// AddFavouriteArtist adds an artist to user_favourite_artists and increments followers on artists collection. func (r *mongoFavouritePlaylistRepository) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { now := float64(time.Now().Unix()) filter := bson.M{"user_id": userID, "artist_id": artistID} @@ -203,8 +215,9 @@ func (r *mongoFavouritePlaylistRepository) AddFavouriteArtist(ctx context.Contex opts := options.UpdateOne().SetUpsert(true) res, err := r.favArtistCol.UpdateOne(ctx, filter, update, opts) if err != nil { - return false, err + return false, fmt.Errorf("failed to add artist favourite: %w", err) } + alreadyExists := res.UpsertedID == nil if !alreadyExists { _, _ = r.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, bson.M{"$inc": bson.M{"followers": 1}}) @@ -212,72 +225,85 @@ func (r *mongoFavouritePlaylistRepository) AddFavouriteArtist(ctx context.Contex return alreadyExists, nil } -// RemoveFavouriteArtist removes an artist from favourites and decrements follower count. +// RemoveFavouriteArtist removes an artist from user_favourite_artists and decrements followers. func (r *mongoFavouritePlaylistRepository) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { filter := bson.M{"user_id": userID, "artist_id": artistID} res, err := r.favArtistCol.DeleteOne(ctx, filter) if err != nil { - return false, err + return false, fmt.Errorf("failed to remove artist favourite: %w", err) } if res.DeletedCount > 0 { _, _ = r.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, bson.M{"$inc": bson.M{"followers": -1}}) - return true, nil } - return false, nil + return res.DeletedCount > 0, nil } -// GetFavouriteArtistIDs returns list of followed artist IDs for a user. +// GetFavouriteArtistIDs returns list of followed artist IDs. func (r *mongoFavouritePlaylistRepository) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { - opts := options.Find().SetProjection(bson.M{"_id": 0, "artist_id": 1}) - cursor, err := r.favArtistCol.Find(ctx, bson.M{"user_id": userID}, opts) + filter := bson.M{"user_id": userID} + cur, err := r.favArtistCol.Find(ctx, filter, options.Find().SetProjection(bson.M{"artist_id": 1, "_id": 0})) if err != nil { return nil, err } - defer cursor.Close(ctx) + defer cur.Close(ctx) var ids []string - for cursor.Next(ctx) { + for cur.Next(ctx) { var doc struct { ArtistID string `bson:"artist_id"` } - if err := cursor.Decode(&doc); err == nil && doc.ArtistID != "" { + if err := cur.Decode(&doc); err == nil && doc.ArtistID != "" { ids = append(ids, doc.ArtistID) } } return ids, nil } -// CreatePlaylist stores a new custom playlist. +// CreatePlaylist inserts a new playlist into user_playlists. func (r *mongoFavouritePlaylistRepository) CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error { - now := float64(time.Now().Unix()) - if p.CreatedAt == 0 { - p.CreatedAt = now - } - p.UpdatedAt = now _, err := r.playlistsCol.InsertOne(ctx, p) return err } -// GetUserPlaylists lists all playlists owned by a user. +// GetUserPlaylists returns all playlists owned by a user sorted by creation date descending. func (r *mongoFavouritePlaylistRepository) GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) { - opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}) - cursor, err := r.playlistsCol.Find(ctx, bson.M{"user_id": userID}, opts) + filter := bson.M{"user_id": userID} + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + cur, err := r.playlistsCol.Find(ctx, filter, opts) if err != nil { return nil, err } - defer cursor.Close(ctx) + defer cur.Close(ctx) - var items []*models.UserPlaylist - if err := cursor.All(ctx, &items); err != nil { + var list []*models.UserPlaylist + for cur.Next(ctx) { + var p models.UserPlaylist + if err := cur.Decode(&p); err == nil { + list = append(list, &p) + } + } + return list, nil +} + +// GetPlaylistByID returns playlist owned by a specific user. +func (r *mongoFavouritePlaylistRepository) GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) { + filter := bson.M{"_id": playlistID, "user_id": userID} + var p models.UserPlaylist + err := r.playlistsCol.FindOne(ctx, filter).Decode(&p) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } return nil, err } - return items, nil + return &p, nil } -// GetPlaylistByID fetches a playlist document by ID. -func (r *mongoFavouritePlaylistRepository) GetPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { +// GetPublicPlaylistByID returns playlist by ID for sharing. +func (r *mongoFavouritePlaylistRepository) GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { + filter := bson.M{"_id": playlistID} var p models.UserPlaylist - err := r.playlistsCol.FindOne(ctx, bson.M{"_id": playlistID}).Decode(&p) + err := r.playlistsCol.FindOne(ctx, filter).Decode(&p) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { return nil, nil @@ -287,220 +313,359 @@ func (r *mongoFavouritePlaylistRepository) GetPlaylistByID(ctx context.Context, return &p, nil } -// UpdatePlaylist updates title, description, or cover of a playlist. -func (r *mongoFavouritePlaylistRepository) UpdatePlaylist(ctx context.Context, playlistID string, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) { +// RenamePlaylist renames a playlist. +func (r *mongoFavouritePlaylistRepository) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) { now := float64(time.Now().Unix()) + filter := bson.M{"_id": playlistID, "user_id": userID} update := bson.M{ "$set": bson.M{ - "updated_at": now, + "name": name, + "cover_id": coverID, + "cover_url": coverURL, + "normal_thumbnail": normalThumbnail, + "updated_at": now, }, } - setFields := update["$set"].(bson.M) - if title != "" { - setFields["title"] = title - } - if description != "" { - setFields["description"] = description - } - if coverURL != "" { - setFields["cover_url"] = coverURL - } - - filter := bson.M{"_id": playlistID, "user_id": userID} opts := options.FindOneAndUpdate().SetReturnDocument(options.After) - var updated models.UserPlaylist - err := r.playlistsCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated) + var p models.UserPlaylist + err := r.playlistsCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&p) if err != nil { - if errors.Is(err, mongo.ErrNoDocuments) { - return nil, nil - } return nil, err } - return &updated, nil + return &p, nil } -// DeletePlaylist deletes a playlist and all its associated track links. -func (r *mongoFavouritePlaylistRepository) DeletePlaylist(ctx context.Context, playlistID string, userID int64) (bool, error) { +// DeletePlaylist removes a playlist and its associated tracks. +func (r *mongoFavouritePlaylistRepository) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { filter := bson.M{"_id": playlistID, "user_id": userID} res, err := r.playlistsCol.DeleteOne(ctx, filter) if err != nil { - return false, err + return err } - if res.DeletedCount > 0 { - _, _ = r.playlistTrkCol.DeleteMany(ctx, bson.M{"playlist_id": playlistID}) - return true, nil + if res.DeletedCount == 0 { + return errors.New("playlist not found") } - return false, nil + _, _ = r.playlistTrkCol.DeleteMany(ctx, bson.M{"playlist_id": playlistID}) + return nil } -// AddTracksToPlaylist inserts tracks into playlistTracks and updates playlist track_count. -func (r *mongoFavouritePlaylistRepository) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error { - if len(trackIDs) == 0 { - return nil +// AddTracksToPlaylist adds tracks with incremental position numbers. +func (r *mongoFavouritePlaylistRepository) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) { + var last models.PlaylistTrack + lastOpt := options.FindOne().SetSort(bson.D{{Key: "position", Value: -1}}) + nextPos := 1 + if err := r.playlistTrkCol.FindOne(ctx, bson.M{"playlist_id": playlistID}, lastOpt).Decode(&last); err == nil { + nextPos = last.Position + 1 } - now := float64(time.Now().Unix()) - // Find current max position - opts := options.FindOne().SetSort(bson.D{{Key: "position", Value: -1}}).SetProjection(bson.M{"position": 1}) - var lastDoc struct { - Position int `bson:"position"` - } - pos := 0 - if err := r.playlistTrkCol.FindOne(ctx, bson.M{"playlist_id": playlistID}, opts).Decode(&lastDoc); err == nil { - pos = lastDoc.Position + 1 - } + now := float64(time.Now().Unix()) + addedCount := 0 for _, tid := range trackIDs { - id := fmt.Sprintf("%s_%s", playlistID, tid) - pt := models.PlaylistTrack{ - ID: id, - PlaylistID: playlistID, - TrackID: tid, - Position: pos, - AddedAt: now, + filter := bson.M{"playlist_id": playlistID, "track_id": tid} + update := bson.M{ + "$setOnInsert": bson.M{"position": nextPos, "added_at": now}, + "$set": bson.M{"playlist_id": playlistID, "track_id": tid}, } - _, err := r.playlistTrkCol.UpdateOne( - ctx, - bson.M{"_id": id}, - bson.M{"$set": pt}, - options.UpdateOne().SetUpsert(true), - ) + opts := options.UpdateOne().SetUpsert(true) + res, err := r.playlistTrkCol.UpdateOne(ctx, filter, update, opts) if err != nil { - return err + return addedCount, err + } + if res.UpsertedID != nil { + addedCount++ + nextPos++ } - pos++ } - count, _ := r.playlistTrkCol.CountDocuments(ctx, bson.M{"playlist_id": playlistID}) - _, _ = r.playlistsCol.UpdateOne(ctx, bson.M{"_id": playlistID}, bson.M{"$set": bson.M{"track_count": count, "updated_at": now}}) - return nil + return addedCount, nil } -// RemoveTrackFromPlaylist deletes a track relation from playlist. +// RemoveTrackFromPlaylist deletes a track from playlist_tracks. func (r *mongoFavouritePlaylistRepository) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { - id := fmt.Sprintf("%s_%s", playlistID, trackID) - _, err := r.playlistTrkCol.DeleteOne(ctx, bson.M{"$or": []bson.M{{"_id": id}, {"playlist_id": playlistID, "track_id": trackID}}}) + filter := bson.M{"playlist_id": playlistID, "track_id": trackID} + res, err := r.playlistTrkCol.DeleteOne(ctx, filter) if err != nil { return err } - now := float64(time.Now().Unix()) - count, _ := r.playlistTrkCol.CountDocuments(ctx, bson.M{"playlist_id": playlistID}) - _, _ = r.playlistsCol.UpdateOne(ctx, bson.M{"_id": playlistID}, bson.M{"$set": bson.M{"track_count": count, "updated_at": now}}) + if res.DeletedCount == 0 { + return errors.New("track not found in playlist") + } return nil } -// GetPlaylistTrackIDs returns ordered track IDs for a playlist. -func (r *mongoFavouritePlaylistRepository) GetPlaylistTrackIDs(ctx context.Context, playlistID string) ([]string, error) { +// GetPlaylistTrackIDs returns paginated track IDs in a playlist ordered by position. +func (r *mongoFavouritePlaylistRepository) GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 50 + } + skip := int64((page - 1) * limit) + + filter := bson.M{"playlist_id": playlistID} + total, err := r.playlistTrkCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "position", Value: 1}}). + SetSkip(skip). + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "_id": 0}) + + cur, err := r.playlistTrkCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cur.Close(ctx) + + var ids []string + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + } + } + return ids, total, nil +} + +// GetPlaylistFirstTrackIDs returns the first N track IDs for playlist cover collage generation. +func (r *mongoFavouritePlaylistRepository) GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) { + if limit < 1 { + limit = 4 + } + filter := bson.M{"playlist_id": playlistID} opts := options.Find(). - SetSort(bson.D{{Key: "position", Value: 1}, {Key: "added_at", Value: 1}}). - SetProjection(bson.M{"track_id": 1}) + SetSort(bson.D{{Key: "position", Value: 1}}). + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "_id": 0}) - cursor, err := r.playlistTrkCol.Find(ctx, bson.M{"playlist_id": playlistID}, opts) + cur, err := r.playlistTrkCol.Find(ctx, filter, opts) if err != nil { return nil, err } - defer cursor.Close(ctx) + defer cur.Close(ctx) var ids []string - for cursor.Next(ctx) { + for cur.Next(ctx) { var doc struct { TrackID string `bson:"track_id"` } - if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { ids = append(ids, doc.TrackID) } } return ids, nil } -// ReorderPlaylistTracks updates the position of tracks in the playlist according to trackIDs order. -func (r *mongoFavouritePlaylistRepository) ReorderPlaylistTracks(ctx context.Context, playlistID string, trackIDs []string) error { +// SaveAlbum adds an album to user_albums. +func (r *mongoFavouritePlaylistRepository) SaveAlbum(ctx context.Context, userID int64, albumID string) (bool, error) { now := float64(time.Now().Unix()) - for i, tid := range trackIDs { - id := fmt.Sprintf("%s_%s", playlistID, tid) - _, _ = r.playlistTrkCol.UpdateOne( - ctx, - bson.M{"$or": []bson.M{{"_id": id}, {"playlist_id": playlistID, "track_id": tid}}}, - bson.M{"$set": bson.M{"position": i}}, - ) - } - _, _ = r.playlistsCol.UpdateOne(ctx, bson.M{"_id": playlistID}, bson.M{"$set": bson.M{"updated_at": now}}) - return nil + filter := bson.M{"user_id": userID, "album_id": albumID} + update := bson.M{ + "$setOnInsert": bson.M{"user_id": userID, "album_id": albumID, "saved_at": now}, + "$set": bson.M{"updated_at": now}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.userAlbumsCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return false, fmt.Errorf("failed to save album: %w", err) + } + alreadyExists := res.UpsertedID == nil + return alreadyExists, nil } -// RecordHistory stores an entry in userHistory. -func (r *mongoFavouritePlaylistRepository) RecordHistory(ctx context.Context, userID int64, trackID string, playedAt float64) error { - if playedAt == 0 { - playedAt = float64(time.Now().Unix()) +// GetSavedAlbumIDs returns saved albums for user. +func (r *mongoFavouritePlaylistRepository) GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) ([]string, []float64, int64, error) { + if page < 1 { + page = 1 } - doc := bson.M{ - "user_id": userID, - "track_id": trackID, - "played_at": playedAt, + if limit < 1 { + limit = 50 } - _, err := r.historyCol.InsertOne(ctx, doc) - return err + skip := int64((page - 1) * limit) + + filter := bson.M{"user_id": userID} + total, err := r.userAlbumsCol.CountDocuments(ctx, filter) + if err != nil { + return nil, nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "saved_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(limit)) + + cur, err := r.userAlbumsCol.Find(ctx, filter, opts) + if err != nil { + return nil, nil, 0, err + } + defer cur.Close(ctx) + + var albumIDs []string + var savedAts []float64 + + for cur.Next(ctx) { + var doc models.UserAlbum + if err := cur.Decode(&doc); err == nil && doc.AlbumID != "" { + albumIDs = append(albumIDs, doc.AlbumID) + savedAts = append(savedAts, doc.SavedAt) + } + } + return albumIDs, savedAts, total, nil +} + +// RecordListeningEvents bulk upserts telemetry events into listening_events. +func (r *mongoFavouritePlaylistRepository) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { + if len(events) == 0 { + return 0, nil + } + + modelsList := make([]mongo.WriteModel, 0, len(events)) + now := float64(time.Now().Unix()) + + for _, ev := range events { + if ev.ID == "" { + continue + } + filter := bson.M{"event_id": ev.ID} + if userID != nil { + filter["user_id"] = *userID + } + setFields := bson.M{ + "event_id": ev.ID, + "track_id": ev.TrackID, + "played_at": ev.PlayedAt, + "started_at": ev.StartedAt, + "played_ms": ev.PlayedMs, + "duration_ms": ev.DurationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": ev.Source, + } + if userID != nil { + setFields["user_id"] = *userID + } + if ev.SessionID != "" { + setFields["session_id"] = ev.SessionID + } + + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": setFields, + } + model := mongo.NewUpdateOneModel().SetFilter(filter).SetUpdate(update).SetUpsert(true) + modelsList = append(modelsList, model) + } + + if len(modelsList) == 0 { + return 0, nil + } + + opts := options.BulkWrite().SetOrdered(false) + res, err := r.eventsCol.BulkWrite(ctx, modelsList, opts) + if err != nil { + return 0, fmt.Errorf("bulk write listening events failed: %w", err) + } + return int(res.UpsertedCount + res.ModifiedCount), nil } -// GetUserHistoryTrackIDs returns recent unique played track IDs. +// GetUserHistoryTrackIDs returns recent track IDs from userHistory. func (r *mongoFavouritePlaylistRepository) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) { - if limit <= 0 || limit > 200 { + if limit < 1 { limit = 100 } + filter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + } opts := options.Find(). SetSort(bson.D{{Key: "played_at", Value: -1}}). - SetLimit(int64(limit * 2)). - SetProjection(bson.M{"track_id": 1}) + SetLimit(int64(limit * 2)) - filter := bson.M{"$or": []bson.M{{"user_id": userID}, {"user_id": fmt.Sprintf("%d", userID)}}} - cursor, err := r.historyCol.Find(ctx, filter, opts) + cur, err := r.historyCol.Find(ctx, filter, opts) if err != nil { return nil, err } - defer cursor.Close(ctx) + defer cur.Close(ctx) seen := make(map[string]bool) - var ids []string - for cursor.Next(ctx) { + var ordered []string + + for cur.Next(ctx) { var doc struct { TrackID string `bson:"track_id"` } - if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { if !seen[doc.TrackID] { seen[doc.TrackID] = true - ids = append(ids, doc.TrackID) - if len(ids) >= limit { + ordered = append(ordered, doc.TrackID) + if len(ordered) >= limit { break } } } } - return ids, nil + return ordered, nil } -// RecordListeningEvents saves a batch of telemetry listening events into user_listening_events. -func (r *mongoFavouritePlaylistRepository) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { - if len(events) == 0 { - return nil +// GetUserTopPlayedTrackIDs returns most frequently played track IDs from listening_events. +func (r *mongoFavouritePlaylistRepository) GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) { + if page < 1 { + page = 1 } - var docs []any - for _, ev := range events { - d := bson.M{ - "id": ev.ID, - "user_id": userID, - "track_id": ev.TrackID, - "played_at": ev.PlayedAt, - "started_at": ev.StartedAt, - "played_ms": ev.PlayedMs, - "duration_ms": ev.DurationMs, - "completed": ev.Completed, - "skipped": ev.Skipped, - "source": ev.Source, - "session_id": ev.SessionID, - "created_at": float64(time.Now().Unix()), + if limit < 1 { + limit = 50 + } + skip := int64((page - 1) * limit) + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: bson.M{"user_id": userID}}}, + bson.D{{Key: "$group", Value: bson.M{"_id": "$track_id", "play_count": bson.M{"$sum": 1}}}}, + bson.D{{Key: "$sort", Value: bson.M{"play_count": -1}}}, + } + + countPipeline := append(pipeline, bson.D{{Key: "$count", Value: "total"}}) + countCur, err := r.eventsCol.Aggregate(ctx, countPipeline) + var total int64 + if err == nil { + if countCur.Next(ctx) { + var cnt struct { + Total int64 `bson:"total"` + } + if err := countCur.Decode(&cnt); err == nil { + total = cnt.Total + } } - docs = append(docs, d) + countCur.Close(ctx) } - _, err := r.eventsCol.InsertMany(ctx, docs) - return err + + pipeline = append(pipeline, + bson.D{{Key: "$skip", Value: skip}}, + bson.D{{Key: "$limit", Value: int64(limit)}}, + ) + + cur, err := r.eventsCol.Aggregate(ctx, pipeline) + if err != nil { + return nil, 0, err + } + defer cur.Close(ctx) + + var ids []string + for cur.Next(ctx) { + var doc struct { + ID string `bson:"_id"` + } + if err := cur.Decode(&doc); err == nil && doc.ID != "" { + ids = append(ids, doc.ID) + } + } + return ids, total, nil } diff --git a/internal/repository/history_repo.go b/internal/repository/history_repo.go new file mode 100644 index 0000000..155f914 --- /dev/null +++ b/internal/repository/history_repo.go @@ -0,0 +1,233 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// HistoryRepository manages user playback history and listening telemetry. +type HistoryRepository interface { + RecordPlay(ctx context.Context, userID int64, trackID string, source string) error + GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) + GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) + RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error +} + +type mongoHistoryRepository struct { + histCol *mongo.Collection + userPbCol *mongo.Collection + globPbCol *mongo.Collection + eventsCol *mongo.Collection + tracksCol *mongo.Collection +} + +// NewHistoryRepository creates a MongoDB HistoryRepository. +func NewHistoryRepository(db *database.Client) HistoryRepository { + return &mongoHistoryRepository{ + histCol: db.Collection("userHistory"), + userPbCol: db.Collection("userPlayback"), + globPbCol: db.Collection("globalPlayback"), + eventsCol: db.Collection("listening_events"), + tracksCol: db.Collection("audioTracks"), + } +} + +// RecordPlay records a playback entry in userHistory, userPlayback, and globalPlayback. +func (r *mongoHistoryRepository) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return nil + } + + now := float64(time.Now().Unix()) + + // 1. Log to userHistory + if userID > 0 { + _, _ = r.histCol.InsertOne(ctx, bson.M{ + "user_id": userID, + "track_id": trackID, + "played_at": now, + }) + + // 2. Update userPlayback bucket (bucket = 30-min window) + bucket := int64(now) / 1800 + userPlayID := fmt.Sprintf("%d:%s:%d", userID, trackID, bucket) + _, _ = r.userPbCol.UpdateOne( + ctx, + bson.M{"_id": userPlayID}, + bson.M{ + "$setOnInsert": bson.M{ + "_id": userPlayID, + "user_id": userID, + "track_id": trackID, + "played_at": now, + "bucket": bucket, + "source": source, + }, + "$inc": bson.M{"plays": 1}, + }, + options.UpdateOne().SetUpsert(true), + ) + } + + // 3. Update globalPlayback + inc := bson.M{"plays": 1} + if source != "" { + inc["sources."+source] = 1 + } + _, _ = r.globPbCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{ + "$inc": inc, + "$set": bson.M{ + "last_played_at": now, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + + // 4. Increment track play_count in tracks collection + _, _ = r.tracksCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{"$inc": bson.M{"audio.play_count": 1}}, + ) + + return nil +} + +// GetUserHistory retrieves unique track IDs for a user sorted by played_at desc. +func (r *mongoHistoryRepository) GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + filter := bson.M{"$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }} + + opts := options.Find(). + SetSort(bson.D{{Key: "played_at", Value: -1}}). + SetLimit(int64(limit * 3)) + + cursor, err := r.histCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var orderedIDs []string + seen := make(map[string]bool) + + for cursor.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { + if !seen[doc.TrackID] { + seen[doc.TrackID] = true + orderedIDs = append(orderedIDs, doc.TrackID) + if len(orderedIDs) >= limit { + break + } + } + } + } + + return orderedIDs, nil +} + +// GetUserTopPlayed retrieves track IDs most frequently played by the user. +func (r *mongoHistoryRepository) GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) { + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.M{"user_id": userID}}}, + {{Key: "$group", Value: bson.M{ + "_id": "$track_id", + "plays": bson.M{"$sum": "$plays"}, + }}}, + {{Key: "$sort", Value: bson.D{{Key: "plays", Value: -1}}}}, + {{Key: "$limit", Value: limit}}, + } + + cursor, err := r.userPbCol.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var trackIDs []string + for cursor.Next(ctx) { + var doc struct { + TrackID string `bson:"_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { + trackIDs = append(trackIDs, doc.TrackID) + } + } + + return trackIDs, nil +} + +// RecordListeningEvents saves telemetry events and triggers play count updates. +func (r *mongoHistoryRepository) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + now := float64(time.Now().Unix()) + for _, ev := range events { + eid := strings.TrimSpace(ev.ID) + if eid == "" { + eid = fmt.Sprintf("ev_%d_%s_%d", userID, ev.TrackID, time.Now().UnixNano()) + } + + filter := bson.M{"event_id": eid} + if userID > 0 { + filter["user_id"] = userID + } + + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": bson.M{ + "event_id": eid, + "user_id": userID, + "track_id": ev.TrackID, + "played_at": ev.PlayedAt, + "started_at": ev.StartedAt, + "played_ms": ev.PlayedMs, + "duration_ms": ev.DurationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": ev.Source, + "session_id": ev.SessionID, + }, + } + + _, _ = r.eventsCol.UpdateOne(ctx, filter, update, options.UpdateOne().SetUpsert(true)) + + // Record play in userHistory, userPlayback, and global metrics + if ev.TrackID != "" { + _ = r.RecordPlay(ctx, userID, ev.TrackID, ev.Source) + } + } + + return nil +} diff --git a/internal/repository/track_repo.go b/internal/repository/track_repo.go index 17565e6..001e9e5 100644 --- a/internal/repository/track_repo.go +++ b/internal/repository/track_repo.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "net/url" "regexp" "strings" + "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" @@ -25,6 +27,7 @@ type TrackRepository interface { GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) GetChannelIDs(ctx context.Context) ([]int64, error) IncrementPlayCount(ctx context.Context, id string) error + UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error } @@ -240,15 +243,30 @@ func (r *mongoTrackRepository) GetTopics(ctx context.Context, limit int) ([]*mod pipeline := mongo.Pipeline{ bson.D{{Key: "$match", Value: bson.M{ "deleted": bson.M{"$ne": true}, - "topic_name": bson.M{"$exists": true, "$nin": []interface{}{"", nil}}, + "topic_name": bson.M{"$exists": true, "$nin": []interface{}{"", "null", "None", nil}}, + }}}, + bson.D{{Key: "$sort", Value: bson.D{ + {Key: "updated_at", Value: -1}, + {Key: "source_message_id", Value: -1}, }}}, bson.D{{Key: "$group", Value: bson.M{ - "_id": "$topic_name", - "topic_id": bson.M{"$first": "$topic_id"}, - "tracks_count": bson.M{"$sum": 1}, - "cover_url": bson.M{"$first": "$spotify.cover_url"}, + "_id": "$topic_name", + "topic_id": bson.M{"$first": "$topic_id"}, + "source_chat_id": bson.M{"$first": "$source_chat_id"}, + "cover_url": bson.M{"$first": "$spotify.cover_url"}, + "big_cover_url": bson.M{"$first": "$spotify.big_cover_url"}, + "raw_thumbnails": bson.M{"$push": "$spotify.cover_url"}, + "tracks_count": bson.M{"$sum": 1}, + }}}, + bson.D{{Key: "$project", Value: bson.M{ + "topic_id": 1, + "source_chat_id": 1, + "cover_url": 1, + "big_cover_url": 1, + "thumbnails": bson.M{"$slice": []interface{}{"$raw_thumbnails", 8}}, + "tracks_count": 1, }}}, - bson.D{{Key: "$sort", Value: bson.M{"tracks_count": -1}}}, + bson.D{{Key: "$sort", Value: bson.M{"tracks_count": -1, "_id": 1}}}, bson.D{{Key: "$limit", Value: limit}}, } @@ -259,10 +277,13 @@ func (r *mongoTrackRepository) GetTopics(ctx context.Context, limit int) ([]*mod defer cursor.Close(ctx) var results []struct { - ID string `bson:"_id"` - TopicID int64 `bson:"topic_id"` - TracksCount int64 `bson:"tracks_count"` - CoverURL string `bson:"cover_url"` + ID string `bson:"_id"` + TopicID *int64 `bson:"topic_id"` + SourceChatID *int64 `bson:"source_chat_id"` + TracksCount int64 `bson:"tracks_count"` + CoverURL string `bson:"cover_url"` + BigCoverURL string `bson:"big_cover_url"` + Thumbnails []string `bson:"thumbnails"` } if err := cursor.All(ctx, &results); err != nil { @@ -271,11 +292,49 @@ func (r *mongoTrackRepository) GetTopics(ctx context.Context, limit int) ([]*mod topics := make([]*models.TopicItem, 0, len(results)) for _, res := range results { + name := strings.TrimSpace(res.ID) + if name == "" { + continue + } + + cover := strings.TrimSpace(res.BigCoverURL) + if cover == "" { + cover = strings.TrimSpace(res.CoverURL) + } + + uniqueThumbs := make([]string, 0, 4) + seen := make(map[string]struct{}) + for _, th := range res.Thumbnails { + th = strings.TrimSpace(th) + if th != "" { + if _, ok := seen[th]; !ok { + seen[th] = struct{}{} + uniqueThumbs = append(uniqueThumbs, th) + if len(uniqueThumbs) >= 4 { + break + } + } + } + } + + if cover == "" && len(uniqueThumbs) > 0 { + cover = uniqueThumbs[0] + } + + endpoint := fmt.Sprintf("/topics/%s/tracks", url.PathEscape(name)) + topics = append(topics, &models.TopicItem{ - TopicID: res.TopicID, - TopicName: res.ID, - TracksCount: res.TracksCount, - CoverURL: res.CoverURL, + Name: name, + TopicName: name, + TopicID: res.TopicID, + Count: res.TracksCount, + TracksCount: res.TracksCount, + CoverURL: cover, + ThumbnailURL: cover, + NormalThumbnail: cover, + Thumbnails: uniqueThumbs, + SourceChatID: res.SourceChatID, + Endpoint: endpoint, }) } @@ -323,3 +382,15 @@ func (r *mongoTrackRepository) IncrementPlayCount(ctx context.Context, id string _, err := r.col.UpdateOne(ctx, filter, update) return err } + +func (r *mongoTrackRepository) UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error { + filter := bson.M{"_id": trackID} + update := bson.M{ + "$set": bson.M{ + "telegram.file_ids." + workerID: fileID, + "updated_at": float64(time.Now().Unix()), + }, + } + _, err := r.col.UpdateOne(ctx, filter, update) + return err +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 4a0e6ca..f98a560 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -18,16 +19,41 @@ import ( type UserRepository interface { UpsertUser(ctx context.Context, user *models.User) (*models.User, error) GetUserByID(ctx context.Context, id int64) (*models.User, error) + GetUserByUsername(ctx context.Context, username string) (*models.User, error) + UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error + UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) + + SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error + GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) + DeleteRegistrationOTP(ctx context.Context, userID int64) error + + SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error + GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) + + GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) + SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error + + UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error + SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error + GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) } type mongoUserRepository struct { - usersCol *mongo.Collection + usersCol *mongo.Collection + otpsCol *mongo.Collection + oidcCol *mongo.Collection + authConfigCol *mongo.Collection + botAuthSessionsCol *mongo.Collection } // NewUserRepository creates a UserRepository instance. func NewUserRepository(db *database.Client) UserRepository { return &mongoUserRepository{ - usersCol: db.Collection("users"), + usersCol: db.Collection("users"), + otpsCol: db.Collection("registration_otps"), + oidcCol: db.Collection("oidc_sessions"), + authConfigCol: db.Collection("auth_config"), + botAuthSessionsCol: db.Collection("bot_auth_sessions"), } } @@ -36,24 +62,41 @@ func (r *mongoUserRepository) UpsertUser(ctx context.Context, u *models.User) (* now := float64(time.Now().Unix()) filter := bson.M{"_id": u.ID} - update := bson.M{ - "$set": bson.M{ - "first_name": u.FirstName, - "username": u.Username, - "photo_url": u.PhotoURL, - "profile_url": u.ProfileURL, - "updated_at": now, - }, - "$setOnInsert": bson.M{ - "created_at": now, - }, + setMap := bson.M{ + "updated_at": now, + } + if u.FirstName != "" { + setMap["first_name"] = u.FirstName + } + if u.Username != "" { + setMap["username"] = u.Username + } + if u.PhotoURL != "" { + setMap["photo_url"] = u.PhotoURL + } + if u.ProfileURL != "" { + setMap["profile_url"] = u.ProfileURL } - if u.Telegram != nil { - update["$set"].(bson.M)["telegram"] = u.Telegram + setMap["telegram"] = u.Telegram } if u.Status != "" { - update["$set"].(bson.M)["status"] = u.Status + setMap["status"] = u.Status + } + if u.Password != nil { + setMap["password"] = u.Password + setMap["password_updated_at"] = now + } + if u.RegisteredVia != nil { + setMap["registered_via"] = u.RegisteredVia + } + + update := bson.M{ + "$set": setMap, + "$setOnInsert": bson.M{ + "created_at": now, + "token_version": 0, + }, } opts := options.FindOneAndUpdate(). @@ -82,3 +125,207 @@ func (r *mongoUserRepository) GetUserByID(ctx context.Context, id int64) (*model } return &u, nil } + +// GetUserByUsername fetches a user by canonical username. +func (r *mongoUserRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + username = strings.TrimSpace(strings.ToLower(username)) + if username == "" { + return nil, nil + } + filter := bson.M{"username": username} + var u models.User + err := r.usersCol.FindOne(ctx, filter).Decode(&u) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch user by username %s: %w", username, err) + } + return &u, nil +} + +// UpdateUserCredentials updates the username and password for an existing user. +func (r *mongoUserRepository) UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": userID} + update := bson.M{ + "$set": bson.M{ + "username": username, + "password": pwd, + "username_updated_at": now, + "password_updated_at": now, + "updated_at": now, + }, + } + _, err := r.usersCol.UpdateOne(ctx, filter, update) + return err +} + +// UpdateIntegrations updates integrations.discord and integrations.lastfm. +func (r *mongoUserRepository) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": userID} + setFields := bson.M{ + "updated_at": now, + } + + if req.Discord != nil { + req.Discord.UpdatedAt = now + setFields["integrations.discord"] = req.Discord + } + if req.Lastfm != nil { + req.Lastfm.UpdatedAt = now + setFields["integrations.lastfm"] = req.Lastfm + } + + update := bson.M{"$set": setFields} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After).SetUpsert(true) + var updated models.User + err := r.usersCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated) + if err != nil { + return nil, err + } + if updated.Integrations != nil { + return updated.Integrations, nil + } + return &models.UserIntegrations{}, nil +} + +// SaveRegistrationOTP inserts or updates a registration OTP record. +func (r *mongoUserRepository) SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error { + filter := bson.M{"_id": otp.ID} + update := bson.M{"$set": otp} + opts := options.UpdateOne().SetUpsert(true) + _, err := r.otpsCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// GetRegistrationOTP retrieves a pending registration OTP for a user ID. +func (r *mongoUserRepository) GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) { + filter := bson.M{"_id": userID} + var otp models.RegistrationOTP + err := r.otpsCol.FindOne(ctx, filter).Decode(&otp) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &otp, nil +} + +// DeleteRegistrationOTP deletes a pending registration OTP. +func (r *mongoUserRepository) DeleteRegistrationOTP(ctx context.Context, userID int64) error { + _, err := r.otpsCol.DeleteOne(ctx, bson.M{"_id": userID}) + return err +} + +// SaveOIDCSession saves an authorization state session. +func (r *mongoUserRepository) SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error { + filter := bson.M{"_id": session.ID} + update := bson.M{"$set": session} + opts := options.UpdateOne().SetUpsert(true) + _, err := r.oidcCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// GetAndDeleteOIDCSession retrieves and deletes an authorization state session in one operation. +func (r *mongoUserRepository) GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) { + filter := bson.M{"_id": state} + var session models.OIDCSession + err := r.oidcCol.FindOneAndDelete(ctx, filter).Decode(&session) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +// GetOwnerPassword retrieves the hashed owner/server password from auth_config. +func (r *mongoUserRepository) GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) { + filter := bson.M{"_id": "owner_password"} + var doc struct { + Password *models.PasswordHash `bson:"password"` + } + err := r.authConfigCol.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return doc.Password, nil +} + +// SetOwnerPassword saves or updates the hashed owner/server password in auth_config. +func (r *mongoUserRepository) SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": "owner_password"} + + setMap := bson.M{ + "password": pwd, + "updated_at": now, + } + if updatedBy > 0 { + setMap["updated_by"] = updatedBy + } + + update := bson.M{ + "$set": setMap, + "$setOnInsert": bson.M{ + "created_at": now, + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.authConfigCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// UpdateFCMToken updates the user's FCM push token in the users collection. +func (r *mongoUserRepository) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": userID} + update := bson.M{ + "$set": bson.M{ + "fcm_token": fcmToken, + "updated_at": now, + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.usersCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// SaveBotAuthSession saves or updates a temporary Telegram bot auth session. +func (r *mongoUserRepository) SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error { + filter := bson.M{"_id": session.ID} + update := bson.M{ + "$set": bson.M{ + "_id": session.ID, + "status": session.Status, + "invite_code": session.InviteCode, + "created_at": session.CreatedAt, + "expires_at": session.ExpiresAt, + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.botAuthSessionsCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// GetBotAuthSession retrieves a temporary bot auth session by ID. +func (r *mongoUserRepository) GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) { + filter := bson.M{"_id": sessionID} + var s models.BotAuthSession + err := r.botAuthSessionsCol.FindOne(ctx, filter).Decode(&s) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &s, nil +} + diff --git a/internal/server/docs.go b/internal/server/docs.go index 5f23791..e65c537 100644 --- a/internal/server/docs.go +++ b/internal/server/docs.go @@ -8,7 +8,7 @@ const openAPISpecJSON = `{ "openapi": "3.0.3", "info": { "title": "StreamGO API", - "description": "High-Performance Media Streaming, Discovery, and Telegram MiniApp Backend written in Go", + "description": "High-Performance Media Streaming, Discovery, Access Control, and Telegram MiniApp Backend written in Go", "version": "1.0.0" }, "servers": [ @@ -23,6 +23,11 @@ const openAPISpecJSON = `{ "type": "http", "scheme": "bearer", "bearerFormat": "JWT / v1.." + }, + "guestPassword": { + "type": "apiKey", + "name": "X-Guest-Password", + "in": "header" } } }, @@ -82,6 +87,27 @@ const openAPISpecJSON = `{ "responses": { "200": { "description": "Randomized selection of tracks" } } } }, + "/tracks/shuffle": { + "get": { + "tags": ["Tracks"], + "summary": "Shuffle Library Tracks", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Shuffled track queue" } } + } + }, + "/library/shuffle": { + "get": { + "tags": ["Tracks"], + "summary": "Alias for Library Shuffle", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Shuffled track queue" } } + } + }, "/search": { "get": { "tags": ["Tracks"], @@ -98,92 +124,136 @@ const openAPISpecJSON = `{ "get": { "tags": ["Topics"], "summary": "List Unique Topics", - "description": "Aggregates unique topic names with total track counts and artwork.", "parameters": [ - { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100 } } + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } ], - "responses": { "200": { "description": "Aggregated topic list" } } + "responses": { "200": { "description": "List of distinct topic tags" } } } }, "/topics/{name}/tracks": { "get": { "tags": ["Topics"], - "summary": "Browse Topic Tracks", + "summary": "Get Tracks by Topic", "parameters": [ { "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } } ], - "responses": { "200": { "description": "Paginated tracks belonging to topic" } } + "responses": { "200": { "description": "Tracks matching topic" } } } }, - "/channelids": { - "get": { - "tags": ["Topics"], - "summary": "Get Indexed Channel IDs", - "responses": { "200": { "description": "List of indexed Telegram source channels" } } - } - }, - "/tracks/{id}/stream": { + "/stream/{id}": { "get": { "tags": ["Streaming"], - "summary": "Stream Track Audio", - "description": "Streams raw audio byte stream supporting HTTP 206 Range headers.", + "summary": "Stream Audio Chunk / HTTP 206 Partial Content", + "description": "High-performance streaming directly from Telegram MTProto multi-client connection pool with range requests support.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, - { "name": "Range", "in": "header", "schema": { "type": "string", "example": "bytes=0-1048575" } } + { "name": "Range", "in": "header", "schema": { "type": "string" } } ], "responses": { - "200": { "description": "Complete audio file" }, - "206": { "description": "Partial byte range of audio" }, - "416": { "description": "Range Not Satisfiable" } + "200": { "description": "Full file download" }, + "206": { "description": "Partial byte range stream" } } - }, - "head": { + } + }, + "/tracks/{id}/download": { + "get": { + "tags": ["Streaming"], + "summary": "Download Track File", + "description": "Streams file with Content-Disposition attachment header for direct file downloading.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Audio file download stream" } } + } + }, + "/tracks/{id}/warm": { + "get": { + "tags": ["Streaming"], + "summary": "Warm Track Cache", + "description": "Pre-fetches initial audio chunks into RAM buffer to reduce TTFB on play.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Warm status" } } + } + }, + "/stream/cover/{id}": { + "get": { + "tags": ["Streaming"], + "summary": "Stream Track Album Art / Thumbnail", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Image bytes" } } + } + }, + "/cover/{id}": { + "get": { "tags": ["Streaming"], - "summary": "Inspect Audio Headers", - "description": "Returns audio headers (Content-Length, Accept-Ranges, Content-Type) without body.", + "summary": "Track Cover Redirect", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Audio headers available" } } + "responses": { "302": { "description": "Redirect to cover image URL" } } + } + }, + "/tracks/{id}/lyrics": { + "get": { + "tags": ["Media"], + "summary": "Track Lyrics", + "description": "Returns synchronized or plain lyrics for a track in JSON or text/plain.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["plain", "json"] } } + ], + "responses": { + "200": { "description": "Lyrics content" }, + "404": { "description": "Lyrics not found" } + } } }, "/artists": { "get": { "tags": ["Artists"], "summary": "List Artists", - "description": "Paginated directory of artists indexed from indexed tracks.", "parameters": [ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, - { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } } + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "q", "in": "query", "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Paginated list of artists" } } + "responses": { "200": { "description": "List of artists" } } } }, "/artists/{id}": { "get": { "tags": ["Artists"], "summary": "Get Artist Details", - "description": "Returns artist profile, follower count, top tracks, and discography.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { - "200": { "description": "Artist detail with top tracks and albums" }, - "404": { "description": "Artist not found" } - } + "responses": { "200": { "description": "Artist metadata and biography" } } } }, "/artists/{id}/tracks": { "get": { "tags": ["Artists"], - "summary": "Get Artist Tracks", + "summary": "Get Artist Top / Popular Tracks", "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, - { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Tracks by artist" } } + } + }, + "/artists/{id}/albums": { + "get": { + "tags": ["Artists"], + "summary": "Get Albums by Artist", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Top tracks for artist" } } + "responses": { "200": { "description": "Discography by artist" } } } }, "/albums": { @@ -192,24 +262,20 @@ const openAPISpecJSON = `{ "summary": "List Albums", "parameters": [ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, - { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }, - { "name": "artist", "in": "query", "schema": { "type": "string" } } + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "q", "in": "query", "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Paginated album list" } } + "responses": { "200": { "description": "List of albums" } } } }, "/albums/{id}": { "get": { "tags": ["Albums"], "summary": "Get Album Details", - "description": "Returns album metadata and complete tracklist.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { - "200": { "description": "Album details and ordered tracks" }, - "404": { "description": "Album not found" } - } + "responses": { "200": { "description": "Album metadata and tracks" } } } }, "/albums/{id}/tracks": { @@ -219,24 +285,24 @@ const openAPISpecJSON = `{ "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Album tracks" } } + "responses": { "200": { "description": "Ordered tracks in album" } } } }, "/favourites": { "get": { "tags": ["Favourites"], - "summary": "List User Favourites", - "security": [{ "bearerAuth": [] }], + "summary": "List User Favourite Tracks", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } } ], - "responses": { "200": { "description": "User favorite tracks" } } + "responses": { "200": { "description": "User favourite tracks" } } }, "post": { "tags": ["Favourites"], "summary": "Add Track to Favourites", - "security": [{ "bearerAuth": [] }], + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "requestBody": { "required": true, "content": { @@ -256,7 +322,7 @@ const openAPISpecJSON = `{ "delete": { "tags": ["Favourites"], "summary": "Remove Track from Favourites", - "security": [{ "bearerAuth": [] }], + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -266,34 +332,138 @@ const openAPISpecJSON = `{ "/favourites/ids": { "get": { "tags": ["Favourites"], - "summary": "Get Favourite Track IDs", - "security": [{ "bearerAuth": [] }], + "summary": "Get All Favourite Track IDs", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "responses": { "200": { "description": "List of liked track IDs" } } } }, - "/history": { + "/favourites/artists": { "get": { "tags": ["Favourites"], - "summary": "Get Listening History", - "security": [{ "bearerAuth": [] }], + "summary": "List Followed Artists", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of followed artists" } } + }, + "post": { + "tags": ["Favourites"], + "summary": "Follow Artist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["artist_id"], + "properties": { "artist_id": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Artist followed" } } + } + }, + "/favourites/artists/{id}": { + "delete": { + "tags": ["Favourites"], + "summary": "Unfollow Artist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Artist unfollowed" } } + } + }, + "/favourites/albums": { + "get": { + "tags": ["Favourites"], + "summary": "List Saved Albums", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of saved albums" } } + }, + "post": { + "tags": ["Favourites"], + "summary": "Save Album to Library", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["album_id"], + "properties": { "album_id": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Album saved" } } + } + }, + "/history": { + "get": { + "tags": ["Playback History & Telemetry"], + "summary": "Get Playback History (/history)", + "description": "Returns user playback event history and recently played tracks with timestamps.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "User playback history" } } + } + }, + "/me/history": { + "get": { + "tags": ["Playback History & Telemetry"], + "summary": "Get My History (/me/history)", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } ], "responses": { "200": { "description": "Recent listening history" } } } }, - "/listening-events": { + "/me/top-played": { + "get": { + "tags": ["Playback History & Telemetry"], + "summary": "Get User Top Played Tracks (/me/top-played)", + "description": "Aggregates personal play counts and returns the most played tracks for the current user.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Top played tracks" } } + } + }, + "/me/listening-events": { "post": { - "tags": ["Favourites"], - "summary": "Record Playback Telemetry Events", - "security": [{ "bearerAuth": [] }], + "tags": ["Playback History & Telemetry"], + "summary": "Log Playback Events / Scrobble (/me/listening-events)", + "description": "Records play, pause, seek, completion, and scrobble telemetry events for user and global metrics.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "properties": { "events": { "type": "array", "items": { "type": "object" } } } + "required": ["events"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "required": ["track_id", "event_type"], + "properties": { + "track_id": { "type": "string" }, + "event_type": { "type": "string", "enum": ["play", "scrobble", "pause", "resume", "seek", "complete"] }, + "position_sec": { "type": "number" }, + "duration_sec": { "type": "integer" }, + "timestamp": { "type": "number" } + } + } + } + } } } } @@ -301,17 +471,25 @@ const openAPISpecJSON = `{ "responses": { "200": { "description": "Events recorded" } } } }, + "/listening-events": { + "post": { + "tags": ["Playback History & Telemetry"], + "summary": "Alias for Listening Events (/listening-events)", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "Events recorded" } } + } + }, "/playlists": { "get": { "tags": ["Playlists"], "summary": "List User Playlists", - "security": [{ "bearerAuth": [] }], - "responses": { "200": { "description": "List of playlists owned by user" } } + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of user playlists" } } }, "post": { "tags": ["Playlists"], "summary": "Create Custom Playlist", - "security": [{ "bearerAuth": [] }], + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "requestBody": { "required": true, "content": { @@ -328,7 +506,7 @@ const openAPISpecJSON = `{ } } }, - "responses": { "200": { "description": "Created playlist" } } + "responses": { "200": { "description": "Playlist created" } } } }, "/playlists/{id}": { @@ -338,21 +516,21 @@ const openAPISpecJSON = `{ "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Playlist detail with tracks" } } + "responses": { "200": { "description": "Playlist detail" } } }, "patch": { "tags": ["Playlists"], - "summary": "Update Playlist Metadata", - "security": [{ "bearerAuth": [] }], + "summary": "Update Playlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Updated playlist" } } + "responses": { "200": { "description": "Playlist updated" } } }, "delete": { "tags": ["Playlists"], "summary": "Delete Playlist", - "security": [{ "bearerAuth": [] }], + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -366,15 +544,66 @@ const openAPISpecJSON = `{ "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { "200": { "description": "Ordered tracks in playlist" } } + "responses": { "200": { "description": "Tracks in playlist" } } }, "post": { "tags": ["Playlists"], "summary": "Add Tracks to Playlist", - "security": [{ "bearerAuth": [] }], + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Tracks added" } } + } + }, + "/playlists/{id}/reorder": { + "post": { + "tags": ["Playlists"], + "summary": "Reorder Playlist Tracks", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], + "responses": { "200": { "description": "Playlist reordered" } } + } + }, + "/playlists/available": { + "get": { + "tags": ["Daily Dynamic Playlists"], + "summary": "List Available Dynamic Playlists", + "description": "Returns metadata for all available daily dynamic mixes (Daily Mix 1–6, Discover Weekly, Rediscover, Late Night, Rising, Surprise Me).", + "responses": { "200": { "description": "List of available mixes" } } + } + }, + "/daily-playlist/{key}": { + "get": { + "tags": ["Daily Dynamic Playlists"], + "summary": "Get Daily Dynamic Playlist (/daily-playlist/{key})", + "description": "Generates or retrieves cached daily dynamic mix generated from user listening habits and library genres.", + "parameters": [ + { "name": "key", "in": "path", "required": true, "schema": { "type": "string", "example": "daily_mix_1" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 75 } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { + "200": { "description": "Dynamic playlist with ordered tracks" }, + "400": { "description": "Invalid mix key" } + } + } + }, + "/admin/access/policy": { + "get": { + "tags": ["Access Control Policies"], + "summary": "Get Access Control Policy", + "description": "Returns current registration mode (open, invite, allowlist, closed), Telegram membership enforcement flag, and required channels.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "Current policy" } } + }, + "patch": { + "tags": ["Access Control Policies"], + "summary": "Update Access Control Policy", + "description": "Updates registration mode, Telegram membership enforcement, lock message, or required chats.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "requestBody": { "required": true, "content": { @@ -382,113 +611,596 @@ const openAPISpecJSON = `{ "schema": { "type": "object", "properties": { - "track_id": { "type": "string" }, - "track_ids": { "type": "array", "items": { "type": "string" } } + "registration_mode": { "type": "string", "enum": ["open", "invite", "allowlist", "closed"] }, + "enforce_membership": { "type": "boolean" }, + "lock_message": { "type": "string" } } } } } }, - "responses": { "200": { "description": "Tracks added" } } + "responses": { "200": { "description": "Updated policy" } } } }, - "/playlists/{id}/reorder": { + "/admin/access/required-chats": { "post": { - "tags": ["Playlists"], - "summary": "Reorder Playlist Tracks", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } - ], + "tags": ["Access Control Policies"], + "summary": "Add Required Telegram Chat", + "description": "Registers a Telegram channel or group that users must join before gaining access.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["track_ids"], - "properties": { "track_ids": { "type": "array", "items": { "type": "string" } } } + "required": ["chat_id"], + "properties": { + "chat_id": { "type": "integer" }, + "title": { "type": "string" }, + "invite_link": { "type": "string" }, + "is_private": { "type": "boolean" } + } } } } }, - "responses": { "200": { "description": "Playlist tracks reordered" } } + "responses": { "200": { "description": "Chat added to required policy" } } + }, + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove Required Telegram Chat", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "chat_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Chat removed" } } } }, - "/auth/telegram": { + "/admin/access/required-chats/{chat_id}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove Required Telegram Chat by ID", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "chat_id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Chat removed" } } + } + }, + "/admin/access/invites": { + "get": { + "tags": ["Access Control Policies"], + "summary": "List Invite Codes", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "include_dead", "in": "query", "schema": { "type": "boolean", "default": false } } + ], + "responses": { "200": { "description": "List of active or all invite codes" } } + }, "post": { - "tags": ["Authentication"], - "summary": "Telegram WebApp Login", - "description": "Verifies Telegram initData HMAC-SHA256 signature and returns user session token.", + "tags": ["Access Control Policies"], + "summary": "Create Registration Invite Code", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["init_data"], - "properties": { "init_data": { "type": "string" } } - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "required": ["init_data"], - "properties": { "init_data": { "type": "string" } } + "properties": { + "max_uses": { "type": "integer", "default": 1 }, + "ttl_days": { "type": "integer", "default": 7 }, + "note": { "type": "string" } + } } } } }, - "responses": { - "200": { "description": "Authentication successful" }, - "401": { "description": "Invalid Telegram signature" } - } + "responses": { "200": { "description": "Created invite code" } } } }, - "/auth/telegram/widget": { - "post": { - "tags": ["Authentication"], - "summary": "Telegram Widget Login", - "description": "Authenticates user using Telegram OAuth Login Widget callback data.", - "responses": { "200": { "description": "Authentication successful" } } + "/admin/access/invites/{code}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Revoke Invite Code", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "code", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Invite revoked" } } } }, - "/auth/me": { + "/admin/access/allowlist": { "get": { - "tags": ["Authentication"], - "summary": "Current User Profile", - "security": [{ "bearerAuth": [] }], - "responses": { - "200": { "description": "Current user profile" }, - "401": { "description": "Unauthorized" } - } + "tags": ["Access Control Policies"], + "summary": "List Allowed Users", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of allowlisted user IDs" } } + }, + "post": { + "tags": ["Access Control Policies"], + "summary": "Add User to Allowlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user_id"], + "properties": { + "user_id": { "type": "integer" }, + "note": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "User added to allowlist" } } } }, - "/cover/{id}": { - "get": { - "tags": ["Media"], - "summary": "Track Cover Redirect", - "description": "Redirects to the best available album artwork or thumbnail for the track.", + "/admin/access/allowlist/{user_id}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove User from Allowlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + { "name": "user_id", "in": "path", "required": true, "schema": { "type": "integer" } } ], - "responses": { "302": { "description": "Redirect to cover image URL" } } + "responses": { "200": { "description": "User removed from allowlist" } } } }, - "/tracks/{id}/lyrics": { + "/admin/access/bypass": { "get": { - "tags": ["Media"], - "summary": "Track Lyrics", - "description": "Returns synchronized or plain lyrics for a track in JSON or text/plain.", + "tags": ["Access Control Policies"], + "summary": "List Membership Bypass Users", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of bypassed user IDs" } } + }, + "post": { + "tags": ["Access Control Policies"], + "summary": "Add User to Bypass List", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user_id"], + "properties": { + "user_id": { "type": "integer" }, + "note": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "User added to bypass list" } } + } + }, + "/admin/access/bypass/{user_id}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove User from Bypass List", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "user_id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "User removed from bypass list" } } + } + }, + "/admin/access/users": { + "get": { + "tags": ["Access Control Policies"], + "summary": "List / Search User Accounts", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "q", "in": "query", "schema": { "type": "string" } }, + { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "locked", "restricted"] } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } }, + { "name": "skip", "in": "query", "schema": { "type": "integer", "default": 0 } } + ], + "responses": { "200": { "description": "User list with total count" } } + } + }, + "/admin/access/users/{id}/lock": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Lock User Account", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "revoke_sessions": { "type": "boolean", "default": true } + } + } + } + } + }, + "responses": { "200": { "description": "User locked" } } + } + }, + "/admin/access/users/{id}/unlock": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Unlock User Account", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "User unlocked" } } + } + }, + "/admin/access/users/{id}/revoke-sessions": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Revoke User Active Sessions", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "All active JWT sessions revoked" } } + } + }, + "/admin/access/reverify": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Reverify Telegram Chat Memberships", + "description": "Tests or reverifies whether target user (or caller) meets all required Telegram chat memberships.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_id": { "type": "integer" } + } + } + } + } + }, + "responses": { "200": { "description": "Verification result for each channel" } } + } + }, + "/share/{id}": { + "get": { + "tags": ["Rich Embeds & Share Cards"], + "summary": "OpenGraph / Twitter Preview Card (/share/{id})", + "description": "Renders high-fidelity HTML OpenGraph & Twitter preview cards with dark player UI for social media platforms (Telegram, Discord, Twitter, WhatsApp), or JSON metadata if requested.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, - { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["plain", "json"] } } + { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["html", "json"] } } ], "responses": { - "200": { "description": "Lyrics content" }, - "404": { "description": "Lyrics not found" } + "200": { "description": "HTML rich card or JSON metadata" }, + "404": { "description": "Track or playlist not found" } } } + }, + "/share/tracks/{id}": { + "get": { + "tags": ["Rich Embeds & Share Cards"], + "summary": "Track Share Card Preview", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Track OpenGraph preview" } } + } + }, + "/share/playlists/{id}": { + "get": { + "tags": ["Rich Embeds & Share Cards"], + "summary": "Playlist Share Card Preview", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist OpenGraph preview" } } + } + }, + "/auth/setup/status": { + "get": { + "tags": ["Authentication"], + "summary": "Initial Setup Status", + "description": "Checks if the server requires initial admin password configuration or if owner accounts exist.", + "responses": { "200": { "description": "Setup status" } } + } + }, + "/auth/setup": { + "post": { + "tags": ["Authentication"], + "summary": "Complete Initial Admin Setup", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["password"], + "properties": { "password": { "type": "string" }, "secret_key": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Setup completed" } } + } + }, + "/auth/password": { + "post": { + "tags": ["Authentication"], + "summary": "Direct Username & Password Login", + "description": "Direct login without Telegram bot OTP.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Login token" } } + } + }, + "/auth/password/change": { + "post": { + "tags": ["Authentication"], + "summary": "Change User Password", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["old_password", "new_password"], + "properties": { + "old_password": { "type": "string" }, + "new_password": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Password updated" } } + } + }, + "/auth/register": { + "post": { + "tags": ["Authentication"], + "summary": "Register New Account", + "description": "Initiates user registration by validating user credentials and dispatching a 6-digit OTP code to the Telegram account via the bot.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["userid", "username", "password"], + "properties": { + "userid": { "type": "integer" }, + "username": { "type": "string" }, + "password": { "type": "string" }, + "invite_code": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "OTP sent via Telegram bot" } } + } + }, + "/auth/create": { + "post": { + "tags": ["Authentication"], + "summary": "Alias for Direct User Creation", + "responses": { "200": { "description": "Account created" } } + } + }, + "/auth/validate": { + "post": { + "tags": ["Authentication"], + "summary": "Validate Telegram Registration OTP", + "description": "Verifies the 6-digit OTP sent to Telegram user by the bot, completes registration, and returns authentication token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["userid", "otp"], + "properties": { + "userid": { "type": "integer" }, + "otp": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Registration completed and token issued" } } + } + }, + "/auth/guest": { + "post": { + "tags": ["Authentication"], + "summary": "Guest Login with Guest Password", + "description": "Authenticates using configured GUEST_PASSWORD and returns a guest session token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["guest_password"], + "properties": { "guest_password": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Guest session token" } } + } + }, + "/auth/telegram": { + "post": { + "tags": ["Authentication"], + "summary": "Telegram WebApp Login", + "description": "Verifies Telegram initData HMAC-SHA256 signature and returns user session token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["init_data"], + "properties": { "init_data": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Authentication successful" } } + } + }, + "/auth/telegram/widget": { + "post": { + "tags": ["Authentication"], + "summary": "Telegram Widget Login", + "responses": { "200": { "description": "Authentication successful" } } + } + }, + "/auth/me": { + "get": { + "tags": ["Authentication"], + "summary": "Current User Profile", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { + "200": { "description": "Current user profile" }, + "401": { "description": "Unauthorized" } + } + } + }, + "/auth/fcm-token": { + "post": { + "tags": ["Authentication"], + "summary": "Update FCM Push Token", + "description": "Registers or updates the user Firebase Cloud Messaging device push token matching Api/schemas/auth.py.", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["fcm_token"], + "properties": { + "fcm_token": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { "description": "Token updated" }, + "401": { "description": "Unauthorized" } + } + } + }, + "/auth/telegram/bot-session": { + "post": { + "tags": ["Authentication"], + "summary": "Create Telegram Bot Auth Session", + "description": "Creates a temporary authentication session to log in directly via the Telegram bot matching Api/schemas/auth.py.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "invite_code": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Bot session created" } } + } + }, + "/auth/telegram/bot-session/status": { + "get": { + "tags": ["Authentication"], + "summary": "Check Telegram Bot Auth Session Status", + "description": "Polls status of temporary bot auth session.", + "parameters": [ + { "name": "session_id", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "set_cookie", "in": "query", "schema": { "type": "boolean", "default": true } } + ], + "responses": { "200": { "description": "Session status" } } + } + }, + "/discord/presence": { + "post": { + "tags": ["External Integrations"], + "summary": "Generate Discord Rich Presence Payload", + "description": "Formats current track playback into Discord Rich Presence activity object.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["track_id"], + "properties": { + "track_id": { "type": "string" }, + "title": { "type": "string" }, + "artist": { "type": "string" }, + "duration_sec": { "type": "integer" }, + "elapsed_sec": { "type": "integer" } + } + } + } + } + }, + "responses": { "200": { "description": "Discord presence payload" } } + } + }, + "/sources/channels": { + "get": { + "tags": ["Sources"], + "summary": "List Monitored Telegram Channels", + "responses": { "200": { "description": "Channel list" } } + }, + "post": { + "tags": ["Sources"], + "summary": "Add Monitored Channel", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["channel_id"], + "properties": { "channel_id": { "type": "integer" }, "title": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Channel added" } } + } } } }` @@ -497,17 +1209,275 @@ const swaggerUIHTML = ` - StreamGO - Swagger UI + StreamGO — API Documentation - + + + +
+
+

StreamGO

+ v1.0 +
@@ -526,7 +1496,10 @@ const swaggerUIHTML = ` ], layout: "StandaloneLayout", persistAuthorization: true, - displayRequestDuration: true + displayRequestDuration: true, + defaultModelsExpandDepth: -1, + docExpansion: "list", + syntaxHighlight: { activated: true, theme: "monokai" } }); }; diff --git a/internal/server/router.go b/internal/server/router.go index d83ff85..dc43935 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "net/http" + "os" + "path/filepath" + "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "streamgo/internal/api" "streamgo/internal/api/handlers" "streamgo/internal/config" "streamgo/internal/database" @@ -19,25 +23,34 @@ import ( "streamgo/internal/telegram" ) +var log = logger.New("server") + // Server encapsulates the Chi router, config, and database handle. type Server struct { - Router *chi.Mux - Config *config.Config - DB *database.Client - TG *telegram.Service - start time.Time + Router *chi.Mux + Config *config.Config + DB *database.Client + TG *telegram.Service + distDir string + start time.Time } // New creates and configures a new HTTP router with middlewares and modular routes. -func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server { +func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter *services.AccessFilter) *Server { r := chi.NewRouter() + distDir := resolveDistDir() + if distDir != "" { + log.Infof("[spa] WebX frontend detected and mounted from: %s", distDir) + } + s := &Server{ - Router: r, - Config: cfg, - DB: db, - TG: tg, - start: time.Now(), + Router: r, + Config: cfg, + DB: db, + TG: tg, + distDir: distDir, + start: time.Now(), } // Global Middlewares @@ -46,7 +59,6 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server // Toggleable HTTP request logger: completely silent when APILogs is false r.Use(logger.HTTPMiddleware(cfg.APILogs)) r.Use(middleware.Recoverer) - r.Use(middleware.Timeout(60 * time.Second)) // CORS Configuration corsOrigins := []string{"*"} @@ -57,14 +69,18 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server r.Use(cors.Handler(cors.Options{ AllowedOrigins: corsOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Range"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Range", "X-Secret-Key", "X-Auth-Token"}, ExposedHeaders: []string{"Link", "Content-Length", "Content-Range", "Accept-Ranges"}, AllowCredentials: true, MaxAge: 300, })) + // Intercept browser HTML navigations to serve WebX frontend SPA shell + r.Use(s.SPAMiddleware) + // Core System Routes r.Get("/", s.handleIndex) + r.Head("/", s.handleIndex) r.Get("/health", s.handleHealth) // API Documentation Routes (Swagger UI & ReDoc) @@ -83,13 +99,21 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server artistAlbumRepo := repository.NewArtistAlbumRepository(db) favRepo := repository.NewFavouritePlaylistRepository(db) userRepo := repository.NewUserRepository(db) + historyRepo := repository.NewHistoryRepository(db) + dailyRepo := repository.NewDailyPlaylistRepository(db) + accessRepo := repository.NewAccessControlRepository(db) // Services trackSvc := services.NewTrackService(trackRepo) streamSvc := services.NewStreamService(trackRepo, tg) artistAlbumSvc := services.NewArtistAlbumService(artistAlbumRepo, trackRepo) - favPlaylistSvc := services.NewFavouritePlaylistService(favRepo, trackRepo) + favPlaylistSvc := services.NewFavouritePlaylistService(favRepo, trackRepo, artistAlbumRepo) authSvc := services.NewAuthService(cfg, userRepo) + authSvc.SetAccessControlRepository(accessRepo) + discordSvc := services.NewDiscordService() + histSvc := services.NewHistoryService(historyRepo, trackRepo) + dailySvc := services.NewDailyPlaylistService(dailyRepo, trackRepo) + accessSvc := services.NewAccessControlService(accessRepo) // Handlers trackHandler := handlers.NewTrackHandler(trackSvc) @@ -101,6 +125,12 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server playlistHandler := handlers.NewPlaylistHandler(favPlaylistSvc, authSvc) authHandler := handlers.NewAuthHandler(cfg, authSvc) mediaExtraHandler := handlers.NewMediaExtraHandler(trackSvc) + sourcesHandler := handlers.NewSourcesHandler(cfg, filter, authSvc) + discordHandler := handlers.NewDiscordHandler(discordSvc) + historyHandler := handlers.NewHistoryHandler(histSvc, authSvc) + dailyHandler := handlers.NewDailyPlaylistHandler(dailySvc, authSvc) + accessHandler := handlers.NewAccessControlHandler(accessSvc, authSvc, cfg) + shareHandler := handlers.NewShareHandler(trackRepo, favRepo, cfg) // Register routes trackHandler.Routes(r) @@ -112,13 +142,64 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service) *Server playlistHandler.Routes(r) authHandler.Routes(r) mediaExtraHandler.Routes(r) + sourcesHandler.Routes(r) + discordHandler.Routes(r) + historyHandler.Routes(r) + dailyHandler.Routes(r) + accessHandler.Routes(r) + shareHandler.Routes(r) } + // SPA & Static Asset Catch-All Handler + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + if isAPIOrReservedPath(r.URL.Path) { + api.RespondError(w, http.StatusNotFound, "route not found") + return + } + + if s.distDir != "" { + cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if cleanPath != "" && cleanPath != "." { + targetPath := filepath.Join(s.distDir, cleanPath) + if info, err := os.Stat(targetPath); err == nil && !info.IsDir() { + if cleanPath == "sw.js" || cleanPath == "manifest.json" { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + } else if strings.HasPrefix(cleanPath, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + http.ServeFile(w, r, targetPath) + return + } + } + + // Single Page Application Navigation: Return index.html for GET/HEAD + if r.Method == http.MethodGet || r.Method == http.MethodHead { + indexFile := filepath.Join(s.distDir, "index.html") + if _, err := os.Stat(indexFile); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + http.ServeFile(w, r, indexFile) + return + } + } + } + + api.RespondError(w, http.StatusNotFound, "route not found") + }) + return s } - func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if s.distDir != "" { + indexFile := filepath.Join(s.distDir, "index.html") + if _, err := os.Stat(indexFile); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + http.ServeFile(w, r, indexFile) + return + } + } respondJSON(w, http.StatusOK, map[string]interface{}{ "service": "StreamGO", "status": "running", diff --git a/internal/server/spa.go b/internal/server/spa.go new file mode 100644 index 0000000..e7fd216 --- /dev/null +++ b/internal/server/spa.go @@ -0,0 +1,127 @@ +package server + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// resolveDistDir locates the WebX frontend build directory. +func resolveDistDir() string { + candidates := []string{ + "dist", + "../WebX/dist", + "WebX/dist", + "/app/dist", + "/app/WebX/dist", + } + for _, dir := range candidates { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + if _, err := os.Stat(filepath.Join(dir, "index.html")); err == nil { + return dir + } + } + } + return "" +} + +// isHTMLNavigation checks if a request is a browser HTML page navigation. +func isHTMLNavigation(r *http.Request) bool { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return false + } + + secDest := strings.ToLower(r.Header.Get("Sec-Fetch-Dest")) + if secDest == "document" || secDest == "frame" || secDest == "iframe" { + return true + } + + secMode := strings.ToLower(r.Header.Get("Sec-Fetch-Mode")) + if secMode == "navigate" { + return true + } + + accept := strings.ToLower(r.Header.Get("Accept")) + if strings.Contains(accept, "text/html") { + parts := strings.Split(accept, ",") + if len(parts) > 0 && !strings.HasPrefix(strings.TrimSpace(parts[0]), "application/json") { + return true + } + } + + return false +} + +// isReservedNavigation returns true if HTML navigation should bypass SPA index.html fallback +// (e.g. documentation, audio streams, direct downloads). +func isReservedNavigation(path string) bool { + path = strings.ToLower(strings.TrimSpace(path)) + if path == "/docs" || strings.HasPrefix(path, "/docs/") || + path == "/redoc" || strings.HasPrefix(path, "/redoc/") || + path == "/openapi.json" || + strings.HasPrefix(path, "/assets/") || + strings.HasPrefix(path, "/auth/") || + strings.HasPrefix(path, "/covers/") || + strings.HasPrefix(path, "/stream/") || + strings.HasPrefix(path, "/download/") || + (strings.HasPrefix(path, "/tracks/") && (strings.HasSuffix(path, "/stream") || strings.HasSuffix(path, "/download"))) { + return true + } + return false +} + +// isAPIOrReservedPath returns true if the URL path belongs to backend API, docs, or media routes. +func isAPIOrReservedPath(path string) bool { + path = strings.ToLower(strings.TrimSpace(path)) + prefixes := []string{ + "/api", + "/auth", + "/tracks", + "/stream", + "/download", + "/artists", + "/albums", + "/favourites", + "/playlists", + "/topics", + "/history", + "/me", + "/listening-events", + "/daily-playlist", + "/admin", + "/share", + "/sources", + "/discord", + "/health", + "/docs", + "/redoc", + "/openapi.json", + } + for _, p := range prefixes { + if path == p || strings.HasPrefix(path, p+"/") { + return true + } + } + return false +} + +// SPAMiddleware intercepts browser HTML page navigations and serves the frontend SPA shell +// (index.html), preserving API endpoints for fetch/XHR calls. +func (s *Server) SPAMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.distDir != "" && isHTMLNavigation(r) { + path := strings.ToLower(strings.TrimSpace(r.URL.Path)) + if !isReservedNavigation(path) { + indexFile := filepath.Join(s.distDir, "index.html") + if _, err := os.Stat(indexFile); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + http.ServeFile(w, r, indexFile) + return + } + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/services/access_control_service.go b/internal/services/access_control_service.go new file mode 100644 index 0000000..7333f68 --- /dev/null +++ b/internal/services/access_control_service.go @@ -0,0 +1,268 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// AccessControlService handles access control policies, invites, allowlists, and user account state. +type AccessControlService struct { + repo repository.AccessControlRepository +} + +// NewAccessControlService creates a new AccessControlService. +func NewAccessControlService(repo repository.AccessControlRepository) *AccessControlService { + return &AccessControlService{repo: repo} +} + +// GetPolicy retrieves the active access policy. +func (s *AccessControlService) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { + return s.repo.GetPolicy(ctx) +} + +// UpdatePolicy applies partial updates to AccessPolicy. +func (s *AccessControlService) UpdatePolicy(ctx context.Context, patch models.PolicyPatch, adminID int64) (*models.AccessPolicy, error) { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil, err + } + + if patch.RegistrationMode != nil { + mode := strings.ToLower(strings.TrimSpace(*patch.RegistrationMode)) + if mode != "open" && mode != "invite" && mode != "allowlist" && mode != "closed" { + return nil, errors.New("invalid registration_mode (must be open, invite, allowlist, closed)") + } + policy.RegistrationMode = mode + } + + if patch.EnforceMembership != nil { + policy.EnforceMembership = *patch.EnforceMembership + } + if patch.LockMessage != nil { + policy.LockMessage = *patch.LockMessage + } + if patch.RequiredChats != nil { + policy.RequiredChats = *patch.RequiredChats + } + + policy.UpdatedAt = float64(time.Now().Unix()) + policy.UpdatedBy = adminID + + if err := s.repo.SavePolicy(ctx, policy); err != nil { + return nil, err + } + return policy, nil +} + +// AddRequiredChat adds a chat to required chats in policy. +func (s *AccessControlService) AddRequiredChat(ctx context.Context, chat models.RequiredChat) (*models.AccessPolicy, error) { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil, err + } + + for i, c := range policy.RequiredChats { + if c.ChatID == chat.ChatID { + policy.RequiredChats[i] = chat + _ = s.repo.SavePolicy(ctx, policy) + return policy, nil + } + } + + policy.RequiredChats = append(policy.RequiredChats, chat) + if err := s.repo.SavePolicy(ctx, policy); err != nil { + return nil, err + } + return policy, nil +} + +// RemoveRequiredChat removes a chat from required chats in policy. +func (s *AccessControlService) RemoveRequiredChat(ctx context.Context, chatID int64) (*models.AccessPolicy, error) { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil, err + } + + var filtered []models.RequiredChat + for _, c := range policy.RequiredChats { + if c.ChatID != chatID { + filtered = append(filtered, c) + } + } + policy.RequiredChats = filtered + if err := s.repo.SavePolicy(ctx, policy); err != nil { + return nil, err + } + return policy, nil +} + +// CreateInvite generates a random invite code. +func (s *AccessControlService) CreateInvite(ctx context.Context, adminID int64, maxUses int, ttlDays int, note string) (*models.InviteCode, error) { + if maxUses <= 0 { + maxUses = 1 + } + + randBytes := make([]byte, 6) + _, _ = rand.Read(randBytes) + code := strings.ToUpper(hex.EncodeToString(randBytes)) + + now := float64(time.Now().Unix()) + var expiresAt *float64 + if ttlDays > 0 { + exp := now + float64(ttlDays*86400) + expiresAt = &exp + } + + inv := &models.InviteCode{ + Code: code, + MaxUses: maxUses, + UsedCount: 0, + CreatedBy: adminID, + CreatedAt: now, + ExpiresAt: expiresAt, + Note: note, + Revoked: false, + } + + if err := s.repo.CreateInvite(ctx, inv); err != nil { + return nil, err + } + return inv, nil +} + +// RevokeInvite revokes an invite code. +func (s *AccessControlService) RevokeInvite(ctx context.Context, code string) bool { + return s.repo.RevokeInvite(ctx, code) +} + +// ListInvites lists invite codes. +func (s *AccessControlService) ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) { + return s.repo.ListInvites(ctx, includeDead) +} + +// AddToAllowlist adds a user ID to allowlist. +func (s *AccessControlService) AddToAllowlist(ctx context.Context, userID int64, adminID int64, note string) error { + entry := &models.AllowlistEntry{ + UserID: userID, + AddedBy: adminID, + AddedAt: float64(time.Now().Unix()), + Note: note, + } + return s.repo.AddToAllowlist(ctx, entry) +} + +// RemoveFromAllowlist removes a user ID from allowlist. +func (s *AccessControlService) RemoveFromAllowlist(ctx context.Context, userID int64) bool { + return s.repo.RemoveFromAllowlist(ctx, userID) +} + +// ListAllowlist lists all allowlisted users. +func (s *AccessControlService) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { + return s.repo.ListAllowlist(ctx) +} + +// AddToBypass adds a user to bypass list. +func (s *AccessControlService) AddToBypass(ctx context.Context, userID int64, adminID int64, note string) error { + entry := &models.AllowlistEntry{ + UserID: userID, + AddedBy: adminID, + AddedAt: float64(time.Now().Unix()), + Note: note, + } + return s.repo.AddToBypass(ctx, entry) +} + +// RemoveFromBypass removes a user from bypass list. +func (s *AccessControlService) RemoveFromBypass(ctx context.Context, userID int64) bool { + return s.repo.RemoveFromBypass(ctx, userID) +} + +// IsInBypass checks if a user is in the bypass list. +func (s *AccessControlService) IsInBypass(ctx context.Context, userID int64) bool { + return s.repo.IsInBypass(ctx, userID) +} + +// ListBypass lists bypass entries. +func (s *AccessControlService) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { + return s.repo.ListBypass(ctx) +} + +// ListUsers lists user accounts for administration. +func (s *AccessControlService) ListUsers(ctx context.Context, query string, status string, limit int, skip int) (map[string]any, error) { + if limit <= 0 { + limit = 50 + } + users, total, err := s.repo.ListUsers(ctx, query, status, limit, skip) + if err != nil { + return nil, err + } + return map[string]any{ + "users": users, + "total": total, + "limit": limit, + "skip": skip, + }, nil +} + +// LockUser locks a user account and optionally revokes active sessions. +func (s *AccessControlService) LockUser(ctx context.Context, userID int64, reason string, adminID int64, revokeSessions bool) error { + if err := s.repo.SetUserStatus(ctx, userID, "locked", reason); err != nil { + return err + } + if revokeSessions { + _, _ = s.repo.RevokeUserSessions(ctx, userID) + } + return nil +} + +// UnlockUser unlocks a user account. +func (s *AccessControlService) UnlockUser(ctx context.Context, userID int64) error { + return s.repo.SetUserStatus(ctx, userID, "active", "") +} + +// RevokeSessions revokes all active tokens for a user. +func (s *AccessControlService) RevokeSessions(ctx context.Context, userID int64) (int, error) { + return s.repo.RevokeUserSessions(ctx, userID) +} + +// AssertCanRegister checks registration policy rules against user ID and invite code. +func (s *AccessControlService) AssertCanRegister(ctx context.Context, userID int64, inviteCode string) error { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil // Fallback open if policy check errors + } + + switch policy.RegistrationMode { + case "closed": + return errors.New("registration_closed: New accounts cannot be created at this time") + case "allowlist": + if userID > 0 && !s.repo.IsInAllowlist(ctx, userID) { + return errors.New("not_allowlisted: Your account is not on the registration allowlist") + } + case "invite": + code := strings.ToUpper(strings.TrimSpace(inviteCode)) + if code == "" { + return errors.New("invite_required: An invite code is required to register") + } + inv, err := s.repo.GetInvite(ctx, code) + if err != nil || inv == nil || inv.Revoked { + return errors.New("invite_invalid: That invite code is invalid or has been revoked") + } + if inv.ExpiresAt != nil && float64(time.Now().Unix()) > *inv.ExpiresAt { + return errors.New("invite_expired: That invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return errors.New("invite_exhausted: That invite code has already reached its maximum uses") + } + _ = s.repo.ConsumeInvite(ctx, code) + } + + return nil +} diff --git a/internal/services/access_filter.go b/internal/services/access_filter.go index bf2f273..40074ca 100644 --- a/internal/services/access_filter.go +++ b/internal/services/access_filter.go @@ -2,6 +2,8 @@ package services import ( "context" + "strconv" + "strings" "sync" "time" @@ -18,6 +20,55 @@ const ( FilterModeHybrid = 2 ) +// ParseFilterMode parses string or int values into valid filter mode constants. +func ParseFilterMode(val any) int { + if val == nil { + return FilterModeGroupOnly + } + switch v := val.(type) { + case int: + if v == FilterModeAnyone || v == FilterModeHybrid { + return v + } + return FilterModeGroupOnly + case int64: + if v == int64(FilterModeAnyone) || v == int64(FilterModeHybrid) { + return int(v) + } + return FilterModeGroupOnly + case string: + s := strings.ToLower(strings.TrimSpace(v)) + switch s { + case "0", "group_only", "group", "channel_only": + return FilterModeGroupOnly + case "1", "anyone", "all", "any": + return FilterModeAnyone + case "2", "hybrid", "allowlist", "whitelist": + return FilterModeHybrid + default: + if n, err := strconv.Atoi(s); err == nil { + if n == FilterModeAnyone || n == FilterModeHybrid { + return n + } + } + return FilterModeGroupOnly + } + } + return FilterModeGroupOnly +} + +// FilterModeToString converts a filter mode integer to its canonical string representation. +func FilterModeToString(mode int) string { + switch mode { + case FilterModeAnyone: + return "anyone" + case FilterModeHybrid: + return "hybrid" + default: + return "group_only" + } +} + // AccessFilter checks chat access rights before indexing or streaming media. type AccessFilter struct { cfg *config.Config @@ -25,7 +76,9 @@ type AccessFilter struct { bannedCol *mongo.Collection mu sync.RWMutex + mode int allowedChats map[int64]bool + bannedChats map[int64]bool bannedUsers map[int64]bool lastRefresh time.Time } @@ -38,19 +91,28 @@ func NewAccessFilter(cfg *config.Config, db *database.Client) *AccessFilter { bannedCol = db.Collection("banned_users") } + mode := FilterModeGroupOnly + if cfg != nil { + mode = cfg.FilterMode + } + af := &AccessFilter{ cfg: cfg, col: col, bannedCol: bannedCol, + mode: mode, allowedChats: make(map[int64]bool), + bannedChats: make(map[int64]bool), bannedUsers: make(map[int64]bool), } - if cfg.ChannelID != 0 { - af.allowedChats[cfg.ChannelID] = true - } - if cfg.DumpChannelID != 0 { - af.allowedChats[cfg.DumpChannelID] = true + if cfg != nil { + if cfg.ChannelID != 0 { + af.allowedChats[cfg.ChannelID] = true + } + if cfg.DumpChannelID != 0 { + af.allowedChats[cfg.DumpChannelID] = true + } } return af @@ -66,11 +128,13 @@ func (f *AccessFilter) RefreshCache(ctx context.Context) error { if err == nil { defer cursor.Close(ctx) newAllowed := make(map[int64]bool) - if f.cfg.ChannelID != 0 { - newAllowed[f.cfg.ChannelID] = true - } - if f.cfg.DumpChannelID != 0 { - newAllowed[f.cfg.DumpChannelID] = true + if f.cfg != nil { + if f.cfg.ChannelID != 0 { + newAllowed[f.cfg.ChannelID] = true + } + if f.cfg.DumpChannelID != 0 { + newAllowed[f.cfg.DumpChannelID] = true + } } for cursor.Next(ctx) { @@ -92,25 +156,66 @@ func (f *AccessFilter) RefreshCache(ctx context.Context) error { } } + if f.bannedCol != nil { + bCursor, err := f.bannedCol.Find(ctx, bson.M{}) + if err == nil { + defer bCursor.Close(ctx) + newBanned := make(map[int64]bool) + for bCursor.Next(ctx) { + var bDoc struct { + ChatID int64 `bson:"chat_id"` + UserID int64 `bson:"user_id"` + ID int64 `bson:"_id"` + } + if err := bCursor.Decode(&bDoc); err == nil { + id := bDoc.ChatID + if id == 0 { + id = bDoc.UserID + } + if id == 0 { + id = bDoc.ID + } + if id != 0 { + newBanned[id] = true + } + } + } + f.bannedChats = newBanned + f.bannedUsers = newBanned + } + } + f.lastRefresh = time.Now() return nil } // IsChatAllowed determines if an incoming message or channel is authorized. func (f *AccessFilter) IsChatAllowed(ctx context.Context, chatID int64) bool { - mode := f.cfg.FilterMode + f.mu.RLock() + if f.bannedChats[chatID] || f.bannedUsers[chatID] { + f.mu.RUnlock() + return false + } + + mode := FilterModeGroupOnly + if f.cfg != nil { + mode = f.cfg.FilterMode + } + if f.mode != FilterModeGroupOnly { + mode = f.mode + } - // Mode 1: Anyone is allowed if mode == FilterModeAnyone { + f.mu.RUnlock() return true } - f.mu.RLock() - if time.Since(f.lastRefresh) > 60*time.Second { + if time.Since(f.lastRefresh) > 60*time.Second && f.col != nil { f.mu.RUnlock() _ = f.RefreshCache(ctx) f.mu.RLock() } + allowed := f.allowedChats[chatID] f.mu.RUnlock() diff --git a/internal/services/artist_album_service.go b/internal/services/artist_album_service.go index b1b97c8..b50379f 100644 --- a/internal/services/artist_album_service.go +++ b/internal/services/artist_album_service.go @@ -2,7 +2,6 @@ package services import ( "context" - "math" "streamgo/internal/models" "streamgo/internal/repository" @@ -22,31 +21,29 @@ func NewArtistAlbumService(repo repository.ArtistAlbumRepository, trackRepo repo } } -// ListArtists returns a paginated list of artists. -func (s *ArtistAlbumService) ListArtists(ctx context.Context, page, perPage int) (*models.ArtistsResponse, error) { +// ListArtists returns a paginated list of artists matching Python schema. +func (s *ArtistAlbumService) ListArtists(ctx context.Context, page, perPage int, refresh bool) (*models.ArtistsResponse, error) { if page < 1 { page = 1 } - if perPage < 1 || perPage > 100 { - perPage = 20 + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 } - artists, total, err := s.repo.ListArtists(ctx, page, perPage) + artists, total, err := s.repo.ListArtists(ctx, page, perPage, refresh) if err != nil { return nil, err } - totalPages := 0 - if total > 0 { - totalPages = int(math.Ceil(float64(total) / float64(perPage))) - } - return &models.ArtistsResponse{ - Items: artists, - Total: total, - Page: page, - PerPage: perPage, - TotalPages: totalPages, + Ok: true, + Page: page, + PerPage: perPage, + Total: total, + Items: artists, }, nil } @@ -70,15 +67,32 @@ func (s *ArtistAlbumService) GetArtistDetail(ctx context.Context, id string) (*m browseItems = append(browseItems, t.ToBrowseItem()) } + popularTracks := browseItems + if len(popularTracks) > 10 { + popularTracks = popularTracks[:10] + } + albums, err := s.repo.GetArtistAlbums(ctx, artist.Name) if err != nil { return nil, err } + singles := make([]*models.BrowseItem, 0) + for _, t := range browseItems { + if t.Album == "" { + singles = append(singles, t) + } + } + return &models.ArtistDetail{ - Artist: artist, - TopTracks: browseItems, - Albums: albums, + Ok: true, + Artist: artist, + PopularTracks: popularTracks, + TopTracks: popularTracks, + Releases: albums, + Albums: albums, + Singles: singles, + Tracks: browseItems, }, nil } @@ -104,31 +118,51 @@ func (s *ArtistAlbumService) GetArtistTracks(ctx context.Context, id string, lim return items, nil } -// ListAlbums returns a paginated list of albums. -func (s *ArtistAlbumService) ListAlbums(ctx context.Context, page, perPage int, artistFilter string) (*models.AlbumsResponse, error) { +// GetArtistTracksPaginated returns paginated tracks for a specific artist ID. +func (s *ArtistAlbumService) GetArtistTracksPaginated(ctx context.Context, id string, page, perPage int) ([]*models.BrowseItem, int64, error) { + artist, err := s.repo.GetArtistByID(ctx, id) + if err != nil { + return nil, 0, err + } + if artist == nil { + return nil, 0, nil + } + + tracks, total, err := s.repo.GetArtistTracksPaginated(ctx, artist.Name, page, perPage) + if err != nil { + return nil, 0, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + return items, total, nil +} + +// ListAlbums returns a paginated list of albums matching Python schema. +func (s *ArtistAlbumService) ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) (*models.AlbumsResponse, error) { if page < 1 { page = 1 } - if perPage < 1 || perPage > 100 { - perPage = 20 + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 } - albums, total, err := s.repo.ListAlbums(ctx, page, perPage, artistFilter) + albums, total, err := s.repo.ListAlbums(ctx, page, perPage, artistFilter, refresh) if err != nil { return nil, err } - totalPages := 0 - if total > 0 { - totalPages = int(math.Ceil(float64(total) / float64(perPage))) - } - return &models.AlbumsResponse{ - Items: albums, - Total: total, - Page: page, - PerPage: perPage, - TotalPages: totalPages, + Ok: true, + Page: page, + PerPage: perPage, + Total: total, + Items: albums, }, nil } @@ -153,6 +187,7 @@ func (s *ArtistAlbumService) GetAlbumDetail(ctx context.Context, id string) (*mo } return &models.AlbumDetail{ + Ok: true, Album: album, Tracks: browseItems, }, nil @@ -171,3 +206,17 @@ func (s *ArtistAlbumService) GetAlbumTracks(ctx context.Context, id string) ([]* } return browseItems, nil } + +// GetAlbumTracksPaginated returns paginated tracks for an album. +func (s *ArtistAlbumService) GetAlbumTracksPaginated(ctx context.Context, id string, page, perPage int) ([]*models.BrowseItem, int64, error) { + tracks, total, err := s.repo.GetAlbumTracksPaginated(ctx, id, page, perPage) + if err != nil { + return nil, 0, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + return browseItems, total, nil +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index 09828b8..5c620bd 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -1,8 +1,15 @@ package services import ( + "bytes" "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" "crypto/hmac" + "crypto/rand" + "crypto/rsa" "crypto/sha256" "crypto/subtle" "encoding/base64" @@ -10,21 +17,43 @@ import ( "encoding/json" "errors" "fmt" + "math/big" + randv2 "math/rand/v2" + "net/http" "net/url" + "regexp" "sort" "strconv" "strings" + "sync" "time" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/pbkdf2" + "streamgo/internal/config" "streamgo/internal/models" "streamgo/internal/repository" ) -// AuthService handles Telegram WebApp/Widget authentication and token issuance. +var ( + usernameRegex = regexp.MustCompile(`^[a-z0-9_.]{3,32}$`) + tgUsernameRegex = regexp.MustCompile(`(?i)^[a-z0-9_]{5,32}$`) +) + +// AuthService handles Telegram WebApp/Widget/OAuth authentication and user management. type AuthService struct { - cfg *config.Config - userRepo repository.UserRepository + cfg *config.Config + userRepo repository.UserRepository + accessRepo repository.AccessControlRepository + + botUsernameMu sync.RWMutex + cachedBotUsername string + botUsernameFetched time.Time + + jwksMu sync.RWMutex + jwksKeys map[string]crypto.PublicKey + jwksFetched time.Time } // NewAuthService creates a new AuthService instance. @@ -32,9 +61,15 @@ func NewAuthService(cfg *config.Config, userRepo repository.UserRepository) *Aut return &AuthService{ cfg: cfg, userRepo: userRepo, + jwksKeys: make(map[string]crypto.PublicKey), } } +// SetAccessControlRepository assigns an access control repository for registration policy enforcement. +func (s *AuthService) SetAccessControlRepository(repo repository.AccessControlRepository) { + s.accessRepo = repo +} + func (s *AuthService) getSecretCandidates() [][]byte { candidates := make([][]byte, 0, 3) if s.cfg.SecretKey != "" { @@ -57,6 +92,78 @@ func (s *AuthService) primarySecret() []byte { return []byte("SHA234567JDNKDNSNNFNDKSMSERTYUWERTY") } +// CanonUsername validates and returns lowercase username if valid. +func CanonUsername(value string) string { + s := strings.TrimSpace(strings.ToLower(value)) + if !usernameRegex.MatchString(s) { + return "" + } + return s +} + +// TgUserpicURL returns userpic URL if username matches Telegram format. +func TgUserpicURL(username string) string { + u := strings.TrimSpace(strings.TrimPrefix(username, "@")) + if !tgUsernameRegex.MatchString(u) { + return "" + } + return fmt.Sprintf("https://t.me/i/userpic/320/%s.jpg", u) +} + +// HashPassword hashes a password using PBKDF2-SHA256 matching Python StreamXBot. +func (s *AuthService) HashPassword(password string) (*models.PasswordHash, error) { + if strings.TrimSpace(password) == "" { + return nil, errors.New("password is required") + } + + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + + const iterations = 200000 + dk := pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New) + + return &models.PasswordHash{ + Algo: "pbkdf2_sha256", + Salt: base64.RawURLEncoding.EncodeToString(salt), + Iterations: iterations, + Hash: base64.RawURLEncoding.EncodeToString(dk), + }, nil +} + +// VerifyPassword verifies a password against the stored PBKDF2 hash. +func (s *AuthService) VerifyPassword(password string, stored *models.PasswordHash) bool { + if stored == nil || stored.Algo != "pbkdf2_sha256" { + return false + } + + salt, err := base64.RawURLEncoding.DecodeString(stored.Salt) + if err != nil { + if s2, err2 := base64.URLEncoding.DecodeString(stored.Salt); err2 == nil { + salt = s2 + } else { + return false + } + } + + expected, err := base64.RawURLEncoding.DecodeString(stored.Hash) + if err != nil { + if e2, err2 := base64.URLEncoding.DecodeString(stored.Hash); err2 == nil { + expected = e2 + } else { + return false + } + } + + if len(salt) == 0 || stored.Iterations <= 0 || len(expected) == 0 { + return false + } + + dk := pbkdf2.Key([]byte(password), salt, stored.Iterations, len(expected), sha256.New) + return subtle.ConstantTimeCompare(dk, expected) == 1 +} + // ValidateTelegramInitData verifies the HMAC-SHA256 signature of Telegram WebApp initData. func (s *AuthService) ValidateTelegramInitData(initData string) (*models.User, error) { initData = strings.TrimSpace(initData) @@ -126,15 +233,20 @@ func (s *AuthService) ValidateTelegramInitData(initData string) (*models.User, e return nil, errors.New("invalid user id in init_data") } + name := strings.TrimSpace(tgPayload.FirstName) + if tgPayload.LastName != "" { + name = strings.TrimSpace(name + " " + tgPayload.LastName) + } + profileURL := tgPayload.PhotoURL if profileURL == "" && tgPayload.Username != "" { - profileURL = fmt.Sprintf("https://t.me/i/userpic/320/%s.jpg", tgPayload.Username) + profileURL = TgUserpicURL(tgPayload.Username) } return &models.User{ ID: tgPayload.ID, UserID: tgPayload.ID, - FirstName: tgPayload.FirstName, + FirstName: name, Username: tgPayload.Username, PhotoURL: tgPayload.PhotoURL, ProfileURL: profileURL, @@ -142,66 +254,101 @@ func (s *AuthService) ValidateTelegramInitData(initData string) (*models.User, e ID: tgPayload.ID, Username: tgPayload.Username, }, + Status: "active", }, nil } -// ValidateTelegramWidget verifies callback data from Telegram Login Widget. -func (s *AuthService) ValidateTelegramWidget(req models.TelegramWidgetRequest) (*models.User, error) { - if req.ID <= 0 || req.Hash == "" { - return nil, errors.New("invalid widget payload") - } - +// VerifyTelegramWidgetData verifies HMAC of Telegram Login Widget data. +func (s *AuthService) VerifyTelegramWidgetData(fields map[string]string) bool { botToken := strings.TrimSpace(s.cfg.BotToken) if botToken == "" { - return nil, errors.New("BOT_TOKEN is not configured") + return false } - // Secret key for widget is SHA256(botToken) - secretKey := sha256.Sum256([]byte(botToken)) - - pairs := make(map[string]string) - pairs["id"] = strconv.FormatInt(req.ID, 10) - pairs["first_name"] = req.FirstName - if req.LastName != "" { - pairs["last_name"] = req.LastName + receivedHash := fields["hash"] + if receivedHash == "" { + return false } - if req.Username != "" { - pairs["username"] = req.Username + + authDateStr := fields["auth_date"] + authDate, _ := strconv.ParseInt(authDateStr, 10, 64) + if authDate > 0 && time.Now().Unix()-authDate > 86400 { + return false } - if req.PhotoURL != "" { - pairs["photo_url"] = req.PhotoURL + + allowed := map[string]bool{ + "auth_date": true, + "first_name": true, + "id": true, + "last_name": true, + "photo_url": true, + "username": true, } - pairs["auth_date"] = strconv.FormatInt(req.AuthDate, 10) - keys := make([]string, 0, len(pairs)) - for k := range pairs { - keys = append(keys, k) + var keys []string + for k := range fields { + if allowed[k] && fields[k] != "" { + keys = append(keys, k) + } } sort.Strings(keys) - var checkLines []string + var items []string for _, k := range keys { - checkLines = append(checkLines, fmt.Sprintf("%s=%s", k, pairs[k])) + items = append(items, fmt.Sprintf("%s=%s", k, fields[k])) } - dataCheckString := strings.Join(checkLines, "\n") + dataCheckString := strings.Join(items, "\n") + secretKey := sha256.Sum256([]byte(botToken)) h := hmac.New(sha256.New, secretKey[:]) h.Write([]byte(dataCheckString)) - calculatedHash := hex.EncodeToString(h.Sum(nil)) + calculated := hex.EncodeToString(h.Sum(nil)) + + return subtle.ConstantTimeCompare([]byte(strings.ToLower(calculated)), []byte(strings.ToLower(receivedHash))) == 1 +} + +// ValidateTelegramWidget verifies callback data from Telegram Login Widget. +func (s *AuthService) ValidateTelegramWidget(req models.TelegramWidgetRequest) (*models.User, error) { + if req.ID <= 0 || req.Hash == "" { + return nil, errors.New("invalid widget payload") + } + + fields := map[string]string{ + "id": strconv.FormatInt(req.ID, 10), + "auth_date": strconv.FormatInt(req.AuthDate, 10), + "hash": req.Hash, + } + if req.FirstName != "" { + fields["first_name"] = req.FirstName + } + if req.LastName != "" { + fields["last_name"] = req.LastName + } + if req.Username != "" { + fields["username"] = req.Username + } + if req.PhotoURL != "" { + fields["photo_url"] = req.PhotoURL + } - if subtle.ConstantTimeCompare([]byte(calculatedHash), []byte(req.Hash)) != 1 { + if !s.VerifyTelegramWidgetData(fields) { return nil, errors.New("invalid widget signature") } + name := strings.TrimSpace(req.FirstName) + if req.LastName != "" { + name = strings.TrimSpace(name + " " + req.LastName) + } + profileURL := req.PhotoURL if profileURL == "" && req.Username != "" { - profileURL = fmt.Sprintf("https://t.me/i/userpic/320/%s.jpg", req.Username) + profileURL = TgUserpicURL(req.Username) } return &models.User{ ID: req.ID, UserID: req.ID, - FirstName: req.FirstName, + FirstName: name, Username: req.Username, PhotoURL: req.PhotoURL, ProfileURL: profileURL, @@ -209,6 +356,7 @@ func (s *AuthService) ValidateTelegramWidget(req models.TelegramWidgetRequest) ( ID: req.ID, Username: req.Username, }, + Status: "active", }, nil } @@ -243,11 +391,43 @@ func (s *AuthService) CreateToken(u *models.User) (string, error) { return fmt.Sprintf("v1.%s.%s", payloadB64, sigB64), nil } -// VerifyToken verifies token signature and returns the user ID. -func (s *AuthService) VerifyToken(rawToken string) (int64, error) { +// CreateGuestToken creates a signed token with uid="__api__" for guest/API sessions. +func (s *AuthService) CreateGuestToken() (string, error) { + now := time.Now().Unix() + exp := now + (365 * 24 * 60 * 60) // 1 year + + payload := map[string]any{ + "uid": "__api__", + "user_id": "__api__", + "userid": "__api__", + "iat": now, + "exp": exp, + "first_name": "Guest", + "photo_url": "", + "profile_url": "", + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + secret := s.primarySecret() + + h := hmac.New(sha256.New, secret) + h.Write([]byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return fmt.Sprintf("v1.%s.%s", payloadB64, sigB64), nil +} + +// VerifyTokenClaims parses and verifies an auth token, returning TokenClaims. +// Supports both regular user tokens and guest tokens (uid="__api__"). +func (s *AuthService) VerifyTokenClaims(rawToken string) (*models.TokenClaims, error) { token := strings.TrimSpace(rawToken) if token == "" { - return 0, errors.New("missing auth token") + return nil, errors.New("missing auth token") } if strings.HasPrefix(strings.ToLower(token), "bearer ") { @@ -256,7 +436,7 @@ func (s *AuthService) VerifyToken(rawToken string) (int64, error) { parts := strings.Split(token, ".") if len(parts) != 3 { - return 0, errors.New("invalid auth token format") + return nil, errors.New("invalid auth token format") } // 1. Format v1.{payload_b64}.{sig_b64} @@ -277,73 +457,132 @@ func (s *AuthService) VerifyToken(rawToken string) (int64, error) { } if !matched { - return 0, errors.New("invalid token signature") + return nil, errors.New("invalid token signature") } payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64) if err != nil { - return 0, errors.New("malformed token payload") + return nil, errors.New("malformed token payload") + } + + var rawMap map[string]any + if err := json.Unmarshal(payloadBytes, &rawMap); err != nil { + return nil, errors.New("invalid token payload json") + } + + claims := &models.TokenClaims{} + + // Check expiry + if expVal, ok := rawMap["exp"].(float64); ok && expVal > 0 { + if float64(time.Now().Unix()) > expVal { + return nil, errors.New("auth token expired") + } + claims.Exp = int64(expVal) } - var payload struct { - UID int64 `json:"uid"` - UserID int64 `json:"user_id"` - Exp int64 `json:"exp"` + // Check UID / UserID + uidRaw := rawMap["uid"] + if uidRaw == nil { + uidRaw = rawMap["user_id"] } - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - return 0, errors.New("invalid token payload json") + + if uidStr, ok := uidRaw.(string); ok && (uidStr == "__api__" || uidStr == "guest") { + claims.IsGuest = true + claims.UserID = 0 + claims.FirstName = "Guest" + return claims, nil } - uid := payload.UID - if uid == 0 { - uid = payload.UserID + var uid int64 + switch v := uidRaw.(type) { + case float64: + uid = int64(v) + case string: + uid, _ = strconv.ParseInt(v, 10, 64) } + if uid <= 0 { - return 0, errors.New("invalid user id in token") + return nil, errors.New("invalid user id in token") } - if payload.Exp > 0 && time.Now().Unix() > payload.Exp { - return 0, errors.New("auth token expired") + claims.UserID = uid + if fn, ok := rawMap["first_name"].(string); ok { + claims.FirstName = fn + } + if un, ok := rawMap["username"].(string); ok { + claims.Username = un + } + if pu, ok := rawMap["photo_url"].(string); ok { + claims.PhotoURL = pu + } + if pru, ok := rawMap["profile_url"].(string); ok { + claims.ProfileURL = pru } - return uid, nil + return claims, nil } // 2. Standard JWT fallback: header.payload.signature payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - // Try with std padding if needed if pb, err2 := base64.URLEncoding.DecodeString(parts[1]); err2 == nil { payloadBytes = pb } else { - return 0, errors.New("malformed jwt payload") + return nil, errors.New("malformed jwt payload") } } - var jwtPayload struct { - UID int64 `json:"uid"` - UserID int64 `json:"user_id"` - Exp int64 `json:"exp"` + var rawMap map[string]any + if err := json.Unmarshal(payloadBytes, &rawMap); err != nil { + return nil, errors.New("invalid jwt payload json") } - if err := json.Unmarshal(payloadBytes, &jwtPayload); err != nil { - return 0, errors.New("invalid jwt payload json") + + claims := &models.TokenClaims{} + if expVal, ok := rawMap["exp"].(float64); ok && expVal > 0 { + if float64(time.Now().Unix()) > expVal { + return nil, errors.New("jwt token expired") + } + claims.Exp = int64(expVal) } - uid := jwtPayload.UID - if uid == 0 { - uid = jwtPayload.UserID + uidRaw := rawMap["uid"] + if uidRaw == nil { + uidRaw = rawMap["user_id"] } - if uid <= 0 { - return 0, errors.New("invalid user id in jwt") + + if uidStr, ok := uidRaw.(string); ok && (uidStr == "__api__" || uidStr == "guest") { + claims.IsGuest = true + claims.UserID = 0 + claims.FirstName = "Guest" + return claims, nil } - if jwtPayload.Exp > 0 && time.Now().Unix() > jwtPayload.Exp { - return 0, errors.New("jwt token expired") + var uid int64 + switch v := uidRaw.(type) { + case float64: + uid = int64(v) + case string: + uid, _ = strconv.ParseInt(v, 10, 64) } - return uid, nil + if uid <= 0 { + return nil, errors.New("invalid user id in jwt") + } + + claims.UserID = uid + return claims, nil +} + +// VerifyToken verifies token signature and returns the user ID (or 0 for guest tokens). +func (s *AuthService) VerifyToken(rawToken string) (int64, error) { + claims, err := s.VerifyTokenClaims(rawToken) + if err != nil { + return 0, err + } + return claims.UserID, nil } + // AuthenticateTelegram validates Telegram initData, saves user, and creates token. func (s *AuthService) AuthenticateTelegram(ctx context.Context, initData string) (*models.AuthResponse, error) { u, err := s.ValidateTelegramInitData(initData) @@ -362,9 +601,14 @@ func (s *AuthService) AuthenticateTelegram(ctx context.Context, initData string) } return &models.AuthResponse{ - OK: true, - Token: token, - User: savedUser, + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, }, nil } @@ -386,12 +630,1379 @@ func (s *AuthService) AuthenticateWidget(ctx context.Context, req models.Telegra } return &models.AuthResponse{ - OK: true, - Token: token, - User: savedUser, + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, + }, nil +} + +// VerifyGuestPassword verifies password against GUEST_PASSWORD in config or owner_password in MongoDB auth_config. +func (s *AuthService) VerifyGuestPassword(ctx context.Context, password string) bool { + pwd := strings.TrimSpace(password) + if pwd == "" { + return false + } + + // 1. Direct env GUEST_PASSWORD check + if s.cfg.GuestPassword != "" && pwd == s.cfg.GuestPassword { + return true + } + + // 2. Database owner_password check + stored, err := s.userRepo.GetOwnerPassword(ctx) + if err == nil && stored != nil { + if s.VerifyPassword(pwd, stored) { + return true + } + } + + return false +} + +// GetSetupStatus checks whether owner/server password has been configured. +func (s *AuthService) GetSetupStatus(ctx context.Context) (*models.SetupStatusResponse, error) { + stored, err := s.userRepo.GetOwnerPassword(ctx) + if err != nil { + return nil, err + } + configured := (stored != nil) || (s.cfg.GuestPassword != "") + needsSetup := !configured + + var ownerID *int64 + if len(s.cfg.OwnerIDs) > 0 && s.cfg.OwnerIDs[0] > 0 { + oid := s.cfg.OwnerIDs[0] + ownerID = &oid + } + + return &models.SetupStatusResponse{ + OK: true, + Configured: configured, + NeedsSetup: needsSetup, + OwnerID: ownerID, + }, nil +} + +// SetupOwnerPassword creates initial owner password and returns a guest/API token. +func (s *AuthService) SetupOwnerPassword(ctx context.Context, password string) (string, error) { + status, err := s.GetSetupStatus(ctx) + if err == nil && status.Configured { + return "", errors.New("setup already completed") + } + + pwd := strings.TrimSpace(password) + if len(pwd) < 6 { + return "", errors.New("password must be at least 6 characters") + } + + pwdHash, err := s.HashPassword(pwd) + if err != nil { + return "", err + } + + var updatedBy int64 + if len(s.cfg.OwnerIDs) > 0 { + updatedBy = s.cfg.OwnerIDs[0] + } + + if err := s.userRepo.SetOwnerPassword(ctx, pwdHash, updatedBy); err != nil { + return "", fmt.Errorf("failed to save owner password: %w", err) + } + + return s.CreateGuestToken() +} + +// LoginWithServerPassword authenticates with server/guest password and returns guest token. +func (s *AuthService) LoginWithServerPassword(ctx context.Context, password string) (string, error) { + if !s.VerifyGuestPassword(ctx, password) { + return "", errors.New("invalid credentials") + } + return s.CreateGuestToken() +} + +// ChangeOwnerPassword updates the server password. +func (s *AuthService) ChangeOwnerPassword(ctx context.Context, password string, userID int64) error { + pwd := strings.TrimSpace(password) + if len(pwd) < 6 { + return errors.New("password must be at least 6 characters") + } + + pwdHash, err := s.HashPassword(pwd) + if err != nil { + return err + } + + return s.userRepo.SetOwnerPassword(ctx, pwdHash, userID) +} + +// LoginWithPassword authenticates a user with username and password, or server/guest password. +func (s *AuthService) LoginWithPassword(ctx context.Context, username, password string) (*models.AuthResponse, error) { + // If logging in as guest with server/guest password + if (strings.EqualFold(username, "guest") || strings.TrimSpace(username) == "") && s.VerifyGuestPassword(ctx, password) { + token, err := s.CreateGuestToken() + if err != nil { + return nil, err + } + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: 0, + FirstName: "Guest", + Username: "guest", + }, nil + } + + canon := CanonUsername(username) + if canon == "" { + return nil, errors.New("invalid username") + } + + user, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if user == nil || user.Password == nil { + // Fallback: if username is not in DB but password matches guest password, allow guest session + if s.VerifyGuestPassword(ctx, password) { + token, err := s.CreateGuestToken() + if err != nil { + return nil, err + } + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: 0, + FirstName: "Guest", + Username: "guest", + }, nil + } + return nil, errors.New("invalid credentials") + } + + if !s.VerifyPassword(password, user.Password) { + return nil, errors.New("invalid credentials") + } + + token, err := s.CreateToken(user) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: user.ID, + FirstName: user.FirstName, + Username: user.Username, + PhotoURL: user.PhotoURL, + ProfileURL: user.ProfileURL, + User: user, + }, nil +} + +// TgLogin authenticates via Telegram WebApp initData and allows optional username/password configuration. +func (s *AuthService) TgLogin(ctx context.Context, req models.TgLoginRequest) (*models.AuthResponse, error) { + user, err := s.ValidateTelegramInitData(req.InitData) + if err != nil { + return nil, err + } + + if req.Username != "" { + canon := CanonUsername(req.Username) + if canon == "" { + return nil, errors.New("invalid username") + } + existing, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if existing != nil && existing.ID != user.ID { + return nil, errors.New("username already taken") + } + user.Username = canon + } + + if req.Password != "" { + pwdHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, err + } + user.Password = pwdHash + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, }, nil } +// RegisterAccount initiates registration via Telegram OTP or directly creates user account if UserID <= 0 or Direct is true. +func (s *AuthService) RegisterAccount(ctx context.Context, req models.RegisterRequest) (*models.AuthResponse, error) { + canon := CanonUsername(req.Username) + if canon == "" { + return nil, errors.New("invalid username") + } + + // Check global registration policy (open, invite, allowlist, closed) + var regMode string = "open" + if s.accessRepo != nil { + policy, err := s.accessRepo.GetPolicy(ctx) + if err == nil && policy != nil { + regMode = policy.RegistrationMode + switch strings.ToLower(policy.RegistrationMode) { + case "closed": + return nil, errors.New("registration_closed: New accounts cannot be created at this time") + case "allowlist": + if req.UserID > 0 && !s.accessRepo.IsInAllowlist(ctx, req.UserID) { + return nil, errors.New("not_allowlisted: Your account is not on the registration allowlist") + } + case "invite": + code := strings.ToUpper(strings.TrimSpace(req.InviteCode)) + if code == "" { + return nil, errors.New("invite_required: An invite code is required to register") + } + inv, err := s.accessRepo.GetInvite(ctx, code) + if err != nil || inv == nil || inv.Revoked { + return nil, errors.New("invite_invalid: That invite code is invalid or has been revoked") + } + if inv.ExpiresAt != nil && float64(time.Now().Unix()) > *inv.ExpiresAt { + return nil, errors.New("invite_expired: That invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return nil, errors.New("invite_exhausted: That invite code has already reached its maximum uses") + } + // Atomic consumption happens immediately for direct registration; for OTP registration, consumed on validate + if req.UserID <= 0 { + _ = s.accessRepo.ConsumeInvite(ctx, code) + } + } + + // Required chats membership check + if policy.EnforceMembership && len(policy.RequiredChats) > 0 && req.UserID > 0 && s.cfg.BotToken != "" { + if !s.accessRepo.IsInBypass(ctx, req.UserID) { + for _, chat := range policy.RequiredChats { + ok, err := s.CheckTelegramChatMember(ctx, chat.ChatID, req.UserID) + if err != nil || !ok { + return nil, errors.New("membership_required: Join the required Telegram chat before registering") + } + } + } + } + } + } + + // 1. Direct manual user creation (when UserID <= 0) + if req.UserID <= 0 { + existingUsername, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if existingUsername != nil { + return nil, errors.New("username already taken") + } + + pwdHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, err + } + + userID := req.UserID + if userID <= 0 { + // Generate unique 64-bit user ID (timestamp millis + random offset) + userID = time.Now().UnixNano()/1e6 + int64(randv2.IntN(10000)) + } + + firstName := req.Username + + now := float64(time.Now().Unix()) + var regVia map[string]any + if regMode != "" { + regVia = map[string]any{ + "mode": regMode, + } + if req.InviteCode != "" { + regVia["invite_code"] = strings.ToUpper(strings.TrimSpace(req.InviteCode)) + } + } + + newUser := &models.User{ + ID: userID, + UserID: userID, + Username: canon, + FirstName: firstName, + Password: pwdHash, + Status: "active", + RegisteredVia: regVia, + CreatedAt: now, + UpdatedAt: now, + } + + savedUser, err := s.userRepo.UpsertUser(ctx, newUser) + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + ProfileURL: savedUser.ProfileURL, + PhotoURL: savedUser.PhotoURL, + User: savedUser, + }, nil + } + + // 2. Telegram OTP flow (requires existing Telegram account & running bot) + if strings.TrimSpace(s.cfg.BotToken) == "" { + return nil, errors.New("Bot is not running or ONLY_API is true") + } + + existingUser, err := s.userRepo.GetUserByID(ctx, req.UserID) + if err != nil { + return nil, err + } + if existingUser != nil && existingUser.Password != nil { + return nil, errors.New("User already registered") + } + + existingUsername, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if existingUsername != nil && existingUsername.ID != req.UserID { + return nil, errors.New("username already taken") + } + + pwdHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, err + } + + otp := fmt.Sprintf("%06d", randv2.IntN(1000000)) + now := float64(time.Now().Unix()) + + firstName, tgUsername, photoURL, profileURL := s.GetTelegramProfile(ctx, req.UserID) + if profileURL == "" && tgUsername != "" { + profileURL = TgUserpicURL(tgUsername) + } + if photoURL == "" { + photoURL = profileURL + } + + otpDoc := &models.RegistrationOTP{ + ID: req.UserID, + OTP: otp, + Username: canon, + Password: pwdHash, + FirstName: firstName, + TelegramUsername: tgUsername, + ProfileURL: profileURL, + PhotoURL: photoURL, + InviteCode: strings.ToUpper(strings.TrimSpace(req.InviteCode)), + CreatedAt: now, + } + + if err := s.userRepo.SaveRegistrationOTP(ctx, otpDoc); err != nil { + return nil, fmt.Errorf("failed to save pending registration: %w", err) + } + + msgText := fmt.Sprintf("Your StreamX registration OTP is: `%s`\n\nThis OTP is valid for registration.", otp) + if err := s.SendTelegramMessage(ctx, req.UserID, msgText); err != nil { + return nil, fmt.Errorf("failed to send OTP via Telegram bot. Have you started the bot? (%w)", err) + } + + return &models.AuthResponse{ + OK: true, + UserID: req.UserID, + Username: canon, + FirstName: firstName, + PhotoURL: photoURL, + ProfileURL: profileURL, + }, nil +} + +// ValidateOTP completes user registration after verifying the OTP code. +func (s *AuthService) ValidateOTP(ctx context.Context, req models.ValidateOTPRequest) (*models.AuthResponse, error) { + otpDoc, err := s.userRepo.GetRegistrationOTP(ctx, req.UserID) + if err != nil { + return nil, err + } + if otpDoc == nil { + return nil, errors.New("no pending registration found") + } + + if otpDoc.OTP != strings.TrimSpace(req.OTP) { + return nil, errors.New("invalid OTP") + } + + // Re-check registration policy at completion time (mode may have changed while the OTP was pending) + var regMode string = "open" + if s.accessRepo != nil { + policy, err := s.accessRepo.GetPolicy(ctx) + if err == nil && policy != nil { + regMode = policy.RegistrationMode + switch strings.ToLower(policy.RegistrationMode) { + case "closed": + return nil, errors.New("registration_closed: New accounts cannot be created at this time") + case "allowlist": + if req.UserID > 0 && !s.accessRepo.IsInAllowlist(ctx, req.UserID) { + return nil, errors.New("not_allowlisted: Your account is not on the registration allowlist") + } + case "invite": + code := strings.ToUpper(strings.TrimSpace(otpDoc.InviteCode)) + if code == "" { + return nil, errors.New("invite_required: An invite code is required to register") + } + inv, err := s.accessRepo.GetInvite(ctx, code) + if err != nil || inv == nil || inv.Revoked { + return nil, errors.New("invite_invalid: That invite code is invalid or has been revoked") + } + if inv.ExpiresAt != nil && float64(time.Now().Unix()) > *inv.ExpiresAt { + return nil, errors.New("invite_expired: That invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return nil, errors.New("invite_exhausted: That invite code has already reached its maximum uses") + } + } + + // Required chats membership check + if policy.EnforceMembership && len(policy.RequiredChats) > 0 && req.UserID > 0 && s.cfg.BotToken != "" { + if !s.accessRepo.IsInBypass(ctx, req.UserID) { + for _, chat := range policy.RequiredChats { + ok, err := s.CheckTelegramChatMember(ctx, chat.ChatID, req.UserID) + if err != nil || !ok { + return nil, errors.New("membership_required: Join the required Telegram chat before registering") + } + } + } + } + } + + if otpDoc.InviteCode != "" { + _ = s.accessRepo.ConsumeInvite(ctx, otpDoc.InviteCode) + } + } + + firstName := otpDoc.FirstName + tgUsername := otpDoc.TelegramUsername + profileURL := otpDoc.ProfileURL + photoURL := otpDoc.PhotoURL + + // Try refreshing profile from Telegram + fn, un, ph, pr := s.GetTelegramProfile(ctx, req.UserID) + if fn != "" { + firstName = fn + } + if un != "" { + tgUsername = un + } + if ph != "" { + photoURL = ph + } + if pr != "" { + profileURL = pr + } + + now := float64(time.Now().Unix()) + var regVia map[string]any + if regMode != "" { + regVia = map[string]any{ + "mode": regMode, + } + if otpDoc.InviteCode != "" { + regVia["invite_code"] = otpDoc.InviteCode + } + } + + user := &models.User{ + ID: req.UserID, + UserID: req.UserID, + FirstName: firstName, + Username: otpDoc.Username, + PhotoURL: photoURL, + ProfileURL: profileURL, + Status: "active", + Password: otpDoc.Password, + RegisteredVia: regVia, + Telegram: &models.TelegramInfo{ + ID: req.UserID, + Username: tgUsername, + }, + CreatedAt: now, + UpdatedAt: now, + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("failed to complete registration: %w", err) + } + + _ = s.userRepo.DeleteRegistrationOTP(ctx, req.UserID) + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, + }, nil +} + +// UpdateCredentials updates credentials for an authenticated user. +func (s *AuthService) UpdateCredentials(ctx context.Context, userID int64, username, password string) error { + canon := CanonUsername(username) + if canon == "" { + return errors.New("invalid username") + } + + existing, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return err + } + if existing != nil && existing.ID != userID { + return errors.New("username already taken") + } + + pwdHash, err := s.HashPassword(password) + if err != nil { + return err + } + + return s.userRepo.UpdateUserCredentials(ctx, userID, canon, pwdHash) +} + +// UpdateIntegrations updates integrations.discord and integrations.lastfm for an authenticated user. +func (s *AuthService) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { + return s.userRepo.UpdateIntegrations(ctx, userID, req) +} + +// SendTelegramMessage sends a message to a user via Telegram Bot HTTP API. +func (s *AuthService) SendTelegramMessage(ctx context.Context, chatID int64, text string) error { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return errors.New("bot token not configured") + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + payload := map[string]any{ + "chat_id": chatID, + "text": text, + "parse_mode": "Markdown", + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Description string `json:"description"` + } + _ = json.NewDecoder(resp.Body).Decode(&errResp) + return fmt.Errorf("telegram API error %d: %s", resp.StatusCode, errResp.Description) + } + + return nil +} + +// CheckTelegramChatMember checks whether a user is an active member/admin of a required Telegram chat. +func (s *AuthService) CheckTelegramChatMember(ctx context.Context, chatID int64, userID int64) (bool, error) { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return true, nil + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember?chat_id=%d&user_id=%d", botToken, chatID, userID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return false, err + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var tgResp struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&tgResp); err != nil { + return false, err + } + + if !tgResp.OK { + return false, nil + } + + switch tgResp.Result.Status { + case "member", "administrator", "creator", "restricted": + return true, nil + default: + return false, nil + } +} + +// GetTelegramProfile retrieves user's first name, username, and profile pictures. +func (s *AuthService) GetTelegramProfile(ctx context.Context, userID int64) (firstName, username, photoURL, profileURL string) { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return "", "", "", "" + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getChat?chat_id=%d", botToken, userID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", "", "" + } + + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", "", "" + } + defer resp.Body.Close() + + var tgResp struct { + OK bool `json:"ok"` + Result struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + Photo *struct { + SmallFileID string `json:"small_file_id"` + BigFileID string `json:"big_file_id"` + } `json:"photo"` + } `json:"result"` + } + + if err := json.NewDecoder(resp.Body).Decode(&tgResp); err == nil && tgResp.OK { + name := strings.TrimSpace(tgResp.Result.FirstName) + if tgResp.Result.LastName != "" { + name = strings.TrimSpace(name + " " + tgResp.Result.LastName) + } + firstName = name + username = tgResp.Result.Username + if username != "" { + profileURL = TgUserpicURL(username) + photoURL = profileURL + } + // If username didn't produce a photo URL or user has no username, resolve Telegram photo file + if (profileURL == "" || photoURL == "") && tgResp.Result.Photo != nil { + fileID := tgResp.Result.Photo.BigFileID + if fileID == "" { + fileID = tgResp.Result.Photo.SmallFileID + } + if fileID != "" { + fileURL := s.getTelegramFilePath(ctx, fileID) + if fileURL != "" { + if profileURL == "" { + profileURL = fileURL + } + if photoURL == "" { + photoURL = fileURL + } + } + } + } + } + + // Fallback to database user record if available + if (profileURL == "" || photoURL == "") && s.userRepo != nil && userID > 0 { + if existing, err := s.userRepo.GetUserByID(ctx, userID); err == nil && existing != nil { + if profileURL == "" { + profileURL = existing.ProfileURL + } + if photoURL == "" { + photoURL = existing.PhotoURL + } + } + } + + if profileURL != "" && photoURL == "" { + photoURL = profileURL + } + if photoURL != "" && profileURL == "" { + profileURL = photoURL + } + + return +} + +func (s *AuthService) getTelegramFilePath(ctx context.Context, fileID string) string { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" || fileID == "" { + return "" + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, fileID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "" + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + var getFileResp struct { + OK bool `json:"ok"` + Result struct { + FilePath string `json:"file_path"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&getFileResp); err == nil && getFileResp.OK && getFileResp.Result.FilePath != "" { + return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, getFileResp.Result.FilePath) + } + return "" +} + +// GetTelegramClientID returns the configured Telegram OIDC client ID or extracts it from BOT_TOKEN. +func (s *AuthService) GetTelegramClientID() string { + if s.cfg.TelegramOIDCClientID != "" { + return s.cfg.TelegramOIDCClientID + } + parts := strings.Split(s.cfg.BotToken, ":") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "" +} + +// GetBotUsername queries Telegram Bot API to get the bot's @username with in-memory caching. +func (s *AuthService) GetBotUsername(ctx context.Context) string { + s.botUsernameMu.RLock() + if s.cachedBotUsername != "" && time.Since(s.botUsernameFetched) < time.Hour { + name := s.cachedBotUsername + s.botUsernameMu.RUnlock() + return name + } + s.botUsernameMu.RUnlock() + + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return "" + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "" + } + + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + var getMeResp struct { + OK bool `json:"ok"` + Result struct { + Username string `json:"username"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&getMeResp); err == nil && getMeResp.OK { + s.botUsernameMu.Lock() + s.cachedBotUsername = getMeResp.Result.Username + s.botUsernameFetched = time.Now() + s.botUsernameMu.Unlock() + return getMeResp.Result.Username + } + + return "" +} + +// UpdateFCMToken sets the user's FCM push token. +func (s *AuthService) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { + if userID <= 0 { + return errors.New("unauthorized") + } + return s.userRepo.UpdateFCMToken(ctx, userID, fcmToken) +} + +// CreateBotSession creates a temporary session for direct Telegram bot login matching Python StreamXBot. +func (s *AuthService) CreateBotSession(ctx context.Context, inviteCode string) (map[string]any, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return nil, err + } + sessionID := "auth_" + base64.RawURLEncoding.EncodeToString(b) + now := float64(time.Now().Unix()) + expiresAt := now + 600 + + var invCode string + if inviteCode != "" { + invCode = strings.ToUpper(strings.TrimSpace(inviteCode)) + } + + session := &models.BotAuthSession{ + ID: sessionID, + Status: "pending", + InviteCode: invCode, + CreatedAt: now, + ExpiresAt: expiresAt, + } + + if err := s.userRepo.SaveBotAuthSession(ctx, session); err != nil { + return nil, err + } + + botUsername := s.GetBotUsername(ctx) + tgURL := "" + webURL := "" + if botUsername != "" { + tgURL = fmt.Sprintf("tg://resolve?domain=%s&start=%s", botUsername, sessionID) + webURL = fmt.Sprintf("https://t.me/%s?start=%s", botUsername, sessionID) + } + + return map[string]any{ + "ok": true, + "session_id": sessionID, + "bot_username": botUsername, + "tg_url": tgURL, + "web_url": webURL, + }, nil +} + +// CheckBotSessionStatus checks whether the user has authorized via Telegram bot. +func (s *AuthService) CheckBotSessionStatus(ctx context.Context, sessionID string) (map[string]any, error) { + session, err := s.userRepo.GetBotAuthSession(ctx, sessionID) + if err != nil { + return nil, err + } + if session == nil { + return map[string]any{ + "ok": false, + "status": "not_found", + }, nil + } + + now := float64(time.Now().Unix()) + if session.ExpiresAt < now && session.Status == "pending" { + return map[string]any{ + "ok": false, + "status": "expired", + }, nil + } + + if session.Status == "confirmed" { + return map[string]any{ + "ok": true, + "status": "confirmed", + "token": session.Token, + "user_id": session.UserID, + "first_name": session.FirstName, + "username": session.Username, + "photo_url": session.PhotoURL, + "profile_url": session.ProfileURL, + }, nil + } + + return map[string]any{ + "ok": true, + "status": "pending", + }, nil +} + +// GeneratePKCE creates a secure random code_verifier and code_challenge. +func (s *AuthService) GeneratePKCE() (codeVerifier string, codeChallenge string) { + bytes := make([]byte, 48) + _, _ = rand.Read(bytes) + codeVerifier = base64.RawURLEncoding.EncodeToString(bytes) + + h := sha256.Sum256([]byte(codeVerifier)) + codeChallenge = base64.RawURLEncoding.EncodeToString(h[:]) + return +} + +// StartTelegramOIDC creates an OIDC authorization session and returns the Telegram redirect URL. +func (s *AuthService) StartTelegramOIDC(ctx context.Context, redirectURI, frontendBase, destPath, inviteCode string) (string, error) { + clientID := s.GetTelegramClientID() + if clientID == "" { + return "", errors.New("Telegram Client ID is not configured") + } + + stateBytes := make([]byte, 24) + _, _ = rand.Read(stateBytes) + state := base64.RawURLEncoding.EncodeToString(stateBytes) + + codeVerifier, codeChallenge := s.GeneratePKCE() + now := float64(time.Now().Unix()) + + session := &models.OIDCSession{ + ID: state, + CodeVerifier: codeVerifier, + RedirectURI: redirectURI, + FrontendBase: frontendBase, + DestPath: destPath, + InviteCode: inviteCode, + CreatedAt: now, + } + + if err := s.userRepo.SaveOIDCSession(ctx, session); err != nil { + return "", fmt.Errorf("failed to save OIDC session: %w", err) + } + + origin := s.cfg.TelegramOIDCOrigin + if origin == "" { + origin = frontendBase + } + if origin != "" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { + origin = "https://" + origin + } + + v := url.Values{} + if s.cfg.TelegramOIDCClientSecret != "" { + v.Set("client_id", clientID) + v.Set("bot_id", clientID) + v.Set("origin", origin) + v.Set("redirect_uri", redirectURI) + v.Set("response_type", "code") + v.Set("scope", "openid profile") + v.Set("state", state) + v.Set("code_challenge", codeChallenge) + v.Set("code_challenge_method", "S256") + } else { + v.Set("bot_id", clientID) + v.Set("origin", origin) + v.Set("return_to", redirectURI) + v.Set("request_access", "write") + } + + authURL := fmt.Sprintf("https://oauth.telegram.org/auth?%s", v.Encode()) + return authURL, nil +} + +// fetchJWKS retrieves and parses public keys from Telegram's JWKS endpoint. +func (s *AuthService) fetchJWKS(ctx context.Context) error { + s.jwksMu.RLock() + if len(s.jwksKeys) > 0 && time.Since(s.jwksFetched) < time.Hour { + s.jwksMu.RUnlock() + return nil + } + s.jwksMu.RUnlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://oauth.telegram.org/.well-known/jwks.json", nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var jwks struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` + } + + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("failed to decode JWKS: %w", err) + } + + newKeys := make(map[string]crypto.PublicKey) + for _, k := range jwks.Keys { + if k.Kty == "RSA" && k.N != "" && k.E != "" { + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + continue + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + continue + } + var eInt int + for _, b := range eBytes { + eInt = (eInt << 8) | int(b) + } + newKeys[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: eInt, + } + } else if k.Kty == "EC" && k.X != "" && k.Y != "" { + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + continue + } + yBytes, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + continue + } + var curve elliptic.Curve + if k.Crv == "P-256" || k.Crv == "ES256" { + curve = elliptic.P256() + } else { + continue + } + newKeys[k.Kid] = &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + } else if k.Kty == "OKP" && k.Crv == "Ed25519" && k.X != "" { + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) + if err == nil && len(xBytes) == ed25519.PublicKeySize { + newKeys[k.Kid] = ed25519.PublicKey(xBytes) + } + } + } + + s.jwksMu.Lock() + s.jwksKeys = newKeys + s.jwksFetched = time.Now() + s.jwksMu.Unlock() + + return nil +} + +// ValidateTelegramIDToken decodes and verifies a Telegram ID token against Telegram's JWKS. +func (s *AuthService) ValidateTelegramIDToken(ctx context.Context, idToken string) (jwt.MapClaims, error) { + if err := s.fetchJWKS(ctx); err != nil { + return nil, fmt.Errorf("failed to fetch Telegram JWKS: %w", err) + } + + token, err := jwt.Parse(idToken, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + s.jwksMu.RLock() + pubKey, ok := s.jwksKeys[kid] + s.jwksMu.RUnlock() + if !ok { + // Refresh keys once in case of key rotation + _ = s.fetchJWKS(ctx) + s.jwksMu.RLock() + pubKey, ok = s.jwksKeys[kid] + s.jwksMu.RUnlock() + } + if !ok { + return nil, fmt.Errorf("JWKS key not found for kid: %s", kid) + } + return pubKey, nil + }, jwt.WithIssuer("https://oauth.telegram.org")) + + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid ID token claims") + } + + return claims, nil +} + +// ResolveTelegramUserID resolves numeric Telegram User ID from decoded OIDC claims. +func (s *AuthService) ResolveTelegramUserID(ctx context.Context, claims jwt.MapClaims) (int64, error) { + var tgUserID int64 + + // 1. Raw numeric fields + for _, field := range []string{"id", "user_id", "telegram_id"} { + if val, ok := claims[field]; ok { + switch v := val.(type) { + case float64: + tgUserID = int64(v) + case string: + tgUserID, _ = strconv.ParseInt(v, 10, 64) + } + if tgUserID > 0 { + break + } + } + } + + prefUsername, _ := claims["preferred_username"].(string) + prefUsername = strings.TrimSpace(prefUsername) + + // If ID is missing or a pairwise 64-bit hash (> 10^12) and we have a username, resolve from DB + if (tgUserID <= 0 || tgUserID > 1000000000000) && prefUsername != "" { + canon := CanonUsername(prefUsername) + existing, _ := s.userRepo.GetUserByUsername(ctx, canon) + if existing != nil && existing.ID > 0 && existing.ID < 1000000000000 { + return existing.ID, nil + } + } + + if tgUserID > 0 { + return tgUserID, nil + } + + // Fallback to sub + if subStr, ok := claims["sub"].(string); ok { + if subID, err := strconv.ParseInt(subStr, 10, 64); err == nil && subID > 0 { + return subID, nil + } + } + + return 0, errors.New("cannot extract valid Telegram user ID from claims") +} + +// ExchangeOIDCCode exchanges authorization code for tokens, resolves user, and issues application token. +func (s *AuthService) ExchangeOIDCCode(ctx context.Context, code, state string) (*models.User, string, string, error) { + session, err := s.userRepo.GetAndDeleteOIDCSession(ctx, state) + if err != nil { + return nil, "", "", err + } + if session == nil { + return nil, "", "", errors.New("invalid_state") + } + + if time.Now().Unix()-int64(session.CreatedAt) > 600 { + return nil, "", "", errors.New("expired_session") + } + + clientID := s.GetTelegramClientID() + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {session.RedirectURI}, + "client_id": {clientID}, + "code_verifier": {session.CodeVerifier}, + } + if s.cfg.TelegramOIDCClientSecret != "" { + form.Set("client_secret", s.cfg.TelegramOIDCClientSecret) + } + + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth.telegram.org/token", strings.NewReader(form.Encode())) + if err != nil { + return nil, "", "", err + } + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + tokenResp, err := client.Do(tokenReq) + if err != nil { + return nil, "", "", fmt.Errorf("token exchange request failed: %w", err) + } + defer tokenResp.Body.Close() + + var tokenBody struct { + IDToken string `json:"id_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil { + return nil, "", "", fmt.Errorf("invalid token exchange response: %w", err) + } + + if tokenBody.Error != "" { + return nil, "", "", fmt.Errorf("%s: %s", tokenBody.Error, tokenBody.ErrorDescription) + } + if tokenBody.IDToken == "" { + return nil, "", "", errors.New("missing_id_token") + } + + claims, err := s.ValidateTelegramIDToken(ctx, tokenBody.IDToken) + if err != nil { + return nil, "", "", err + } + + tgUserID, err := s.ResolveTelegramUserID(ctx, claims) + if err != nil { + return nil, "", "", err + } + + name, _ := claims["name"].(string) + username, _ := claims["preferred_username"].(string) + picture, _ := claims["picture"].(string) + + profileURL := picture + if profileURL == "" && username != "" { + profileURL = TgUserpicURL(username) + } + + user := &models.User{ + ID: tgUserID, + UserID: tgUserID, + FirstName: strings.TrimSpace(name), + Username: username, + PhotoURL: picture, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: tgUserID, + Username: username, + }, + Status: "active", + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, "", "", err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, "", "", err + } + + destPath := session.DestPath + if destPath == "" { + destPath = "/" + } + + return savedUser, token, destPath, nil +} + +// ValidateOIDCIDToken handles token verification for frontend hash fragment login (/auth/telegram/validate-token). +func (s *AuthService) ValidateOIDCIDToken(ctx context.Context, idToken, inviteCode string) (*models.User, string, error) { + claims, err := s.ValidateTelegramIDToken(ctx, idToken) + if err != nil { + return nil, "", err + } + + tgUserID, err := s.ResolveTelegramUserID(ctx, claims) + if err != nil { + return nil, "", err + } + + name, _ := claims["name"].(string) + username, _ := claims["preferred_username"].(string) + picture, _ := claims["picture"].(string) + + profileURL := picture + if profileURL == "" && username != "" { + profileURL = TgUserpicURL(username) + } + + user := &models.User{ + ID: tgUserID, + UserID: tgUserID, + FirstName: strings.TrimSpace(name), + Username: username, + PhotoURL: picture, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: tgUserID, + Username: username, + }, + Status: "active", + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, "", err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, "", err + } + + return savedUser, token, nil +} + +// SetAuthCookie writes auth_token as an HTTP-only cookie. +func (s *AuthService) SetAuthCookie(w http.ResponseWriter, token string) { + secure := s.cfg.CookieSecure + + sameSite := http.SameSiteNoneMode + if strings.ToLower(s.cfg.CookieSameSite) == "lax" { + sameSite = http.SameSiteLaxMode + } else if strings.ToLower(s.cfg.CookieSameSite) == "strict" { + sameSite = http.SameSiteStrictMode + } + + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: token, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + MaxAge: 365 * 24 * 60 * 60, + }) +} + +// ClearAuthCookie clears the auth_token cookie. +func (s *AuthService) ClearAuthCookie(w http.ResponseWriter) { + secure := s.cfg.CookieSecure + + sameSite := http.SameSiteNoneMode + if strings.ToLower(s.cfg.CookieSameSite) == "lax" { + sameSite = http.SameSiteLaxMode + } else if strings.ToLower(s.cfg.CookieSameSite) == "strict" { + sameSite = http.SameSiteStrictMode + } + + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + MaxAge: -1, + }) +} + // GetUserByID returns user information by ID. func (s *AuthService) GetUserByID(ctx context.Context, id int64) (*models.User, error) { return s.userRepo.GetUserByID(ctx, id) diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go index 39956ec..80e6125 100644 --- a/internal/services/auth_service_test.go +++ b/internal/services/auth_service_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "net/url" + "strings" "testing" "streamgo/internal/config" @@ -15,7 +16,19 @@ import ( ) type mockUserRepo struct { - users map[int64]*models.User + users map[int64]*models.User + otps map[int64]*models.RegistrationOTP + sessions map[string]*models.BotAuthSession + fcmTokens map[int64]string +} + +func newMockUserRepo() *mockUserRepo { + return &mockUserRepo{ + users: make(map[int64]*models.User), + otps: make(map[int64]*models.RegistrationOTP), + sessions: make(map[string]*models.BotAuthSession), + fcmTokens: make(map[int64]string), + } } func (m *mockUserRepo) UpsertUser(ctx context.Context, user *models.User) (*models.User, error) { @@ -31,6 +44,67 @@ func (m *mockUserRepo) GetUserByID(ctx context.Context, id int64) (*models.User, return u, nil } +func (m *mockUserRepo) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + for _, u := range m.users { + if u.Username == username { + return u, nil + } + } + return nil, nil +} + +func (m *mockUserRepo) UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error { + return nil +} + +func (m *mockUserRepo) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { + return &models.UserIntegrations{}, nil +} + +func (m *mockUserRepo) SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error { + m.otps[otp.ID] = otp + return nil +} + +func (m *mockUserRepo) GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) { + return m.otps[userID], nil +} + +func (m *mockUserRepo) DeleteRegistrationOTP(ctx context.Context, userID int64) error { + delete(m.otps, userID) + return nil +} + +func (m *mockUserRepo) SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error { + return nil +} + +func (m *mockUserRepo) GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) { + return nil, nil +} + +func (m *mockUserRepo) GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) { + return nil, nil +} + +func (m *mockUserRepo) SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error { + return nil +} + +func (m *mockUserRepo) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { + m.fcmTokens[userID] = fcmToken + return nil +} + +func (m *mockUserRepo) SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error { + m.sessions[session.ID] = session + return nil +} + +func (m *mockUserRepo) GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) { + return m.sessions[sessionID], nil +} + func TestTelegramInitDataAuth(t *testing.T) { botToken := "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" cfg := &config.Config{ @@ -110,3 +184,405 @@ func TestVerifyTokenTampered(t *testing.T) { t.Fatalf("expected error for tampered token, got nil") } } + +func TestGuestPasswordAndGuestToken(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{ + SecretKey: "supersecretkey123", + GuestPassword: "testguestpassword", + } + repo := &mockUserRepo{users: make(map[int64]*models.User)} + authSvc := services.NewAuthService(cfg, repo) + + // 1. Check guest password verification + if !authSvc.VerifyGuestPassword(ctx, "testguestpassword") { + t.Fatalf("expected VerifyGuestPassword to return true for correct password") + } + if authSvc.VerifyGuestPassword(ctx, "wrongpassword") { + t.Fatalf("expected VerifyGuestPassword to return false for wrong password") + } + + // 2. Login with server/guest password + tok, err := authSvc.LoginWithServerPassword(ctx, "testguestpassword") + if err != nil { + t.Fatalf("LoginWithServerPassword failed: %v", err) + } + if tok == "" { + t.Fatalf("expected non-empty guest token") + } + + // 3. Verify guest token claims + claims, err := authSvc.VerifyTokenClaims(tok) + if err != nil { + t.Fatalf("VerifyTokenClaims failed for guest token: %v", err) + } + if !claims.IsGuest { + t.Fatalf("expected claims.IsGuest to be true") + } + if claims.UserID != 0 { + t.Fatalf("expected claims.UserID to be 0 for guest, got %d", claims.UserID) + } + + // 4. Setup status + status, err := authSvc.GetSetupStatus(ctx) + if err != nil { + t.Fatalf("GetSetupStatus failed: %v", err) + } + if !status.Configured { + t.Fatalf("expected status.Configured to be true when GuestPassword is set") + } + if status.NeedsSetup { + t.Fatalf("expected status.NeedsSetup to be false") + } +} + +func TestDirectManualUserRegistrationAndLogin(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{ + SecretKey: "supersecretkey123", + } + repo := &mockUserRepo{users: make(map[int64]*models.User)} + authSvc := services.NewAuthService(cfg, repo) + + // 1. Direct manual registration without Telegram UserID + regReq := models.RegisterRequest{ + Username: "misfittest", + Password: "mypassword123", + } + resp, err := authSvc.RegisterAccount(ctx, regReq) + if err != nil { + t.Fatalf("RegisterAccount failed: %v", err) + } + if resp.UserID <= 0 { + t.Fatalf("expected auto-generated positive UserID, got %d", resp.UserID) + } + if resp.Token == "" { + t.Fatalf("expected token returned on direct registration") + } + + // 2. Verify token + claims, err := authSvc.VerifyTokenClaims(resp.Token) + if err != nil { + t.Fatalf("VerifyTokenClaims failed: %v", err) + } + if claims.UserID != resp.UserID { + t.Fatalf("expected verified UserID %d, got %d", resp.UserID, claims.UserID) + } + if claims.IsGuest { + t.Fatalf("expected registered user to not be guest") + } + + // 3. Manual login with password + loginResp, err := authSvc.LoginWithPassword(ctx, "misfittest", "mypassword123") + if err != nil { + t.Fatalf("LoginWithPassword failed: %v", err) + } + if loginResp.UserID != resp.UserID { + t.Fatalf("expected login user ID %d, got %d", resp.UserID, loginResp.UserID) + } + + // 4. Bad password + if _, err := authSvc.LoginWithPassword(ctx, "misfittest", "wrongpw"); err == nil { + t.Fatalf("expected error for wrong password, got nil") + } +} + +type mockAccessRepoForAuth struct { + policy *models.AccessPolicy + invites map[string]*models.InviteCode + allowlist map[int64]bool + bypass map[int64]bool +} + +func newMockAccessRepoForAuth() *mockAccessRepoForAuth { + return &mockAccessRepoForAuth{ + policy: &models.AccessPolicy{ + RegistrationMode: "open", + }, + invites: make(map[string]*models.InviteCode), + allowlist: make(map[int64]bool), + bypass: make(map[int64]bool), + } +} + +func (m *mockAccessRepoForAuth) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { + return m.policy, nil +} +func (m *mockAccessRepoForAuth) SavePolicy(ctx context.Context, p *models.AccessPolicy) error { + m.policy = p + return nil +} +func (m *mockAccessRepoForAuth) CreateInvite(ctx context.Context, inv *models.InviteCode) error { + m.invites[inv.Code] = inv + return nil +} +func (m *mockAccessRepoForAuth) GetInvite(ctx context.Context, code string) (*models.InviteCode, error) { + return m.invites[code], nil +} +func (m *mockAccessRepoForAuth) ConsumeInvite(ctx context.Context, code string) error { + if inv, ok := m.invites[code]; ok { + inv.UsedCount++ + } + return nil +} +func (m *mockAccessRepoForAuth) RevokeInvite(ctx context.Context, code string) bool { return true } +func (m *mockAccessRepoForAuth) ListInvites(ctx context.Context, d bool) ([]*models.InviteCode, error) { + return nil, nil +} +func (m *mockAccessRepoForAuth) AddToAllowlist(ctx context.Context, e *models.AllowlistEntry) error { + m.allowlist[e.UserID] = true + return nil +} +func (m *mockAccessRepoForAuth) RemoveFromAllowlist(ctx context.Context, u int64) bool { return true } +func (m *mockAccessRepoForAuth) IsInAllowlist(ctx context.Context, u int64) bool { + return m.allowlist[u] +} +func (m *mockAccessRepoForAuth) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { + return nil, nil +} +func (m *mockAccessRepoForAuth) AddToBypass(ctx context.Context, e *models.AllowlistEntry) error { + m.bypass[e.UserID] = true + return nil +} +func (m *mockAccessRepoForAuth) RemoveFromBypass(ctx context.Context, u int64) bool { return true } +func (m *mockAccessRepoForAuth) IsInBypass(ctx context.Context, u int64) bool { + return m.bypass[u] +} +func (m *mockAccessRepoForAuth) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { + return nil, nil +} +func (m *mockAccessRepoForAuth) ListUsers(ctx context.Context, q, s string, l, sk int) ([]*models.User, int64, error) { + return nil, 0, nil +} +func (m *mockAccessRepoForAuth) SetUserStatus(ctx context.Context, u int64, s, r string) error { + return nil +} +func (m *mockAccessRepoForAuth) RevokeUserSessions(ctx context.Context, u int64) (int, error) { + return 0, nil +} + +func TestOTPRegistrationFlow(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{ + SecretKey: "supersecretkey123", + BotToken: "test:token", + } + userRepo := newMockUserRepo() + accessRepo := newMockAccessRepoForAuth() + authSvc := services.NewAuthService(cfg, userRepo) + authSvc.SetAccessControlRepository(accessRepo) + + // Direct call to validate without pending registration should fail + _, err := authSvc.ValidateOTP(ctx, models.ValidateOTPRequest{ + UserID: 12345, + OTP: "123456", + }) + if err == nil || err.Error() != "no pending registration found" { + t.Fatalf("expected 'no pending registration found', got %v", err) + } + + // Manually save an OTP doc into repo (simulating bot having sent OTP) + pwdHash, _ := authSvc.HashPassword("securepass") + userRepo.otps[12345] = &models.RegistrationOTP{ + ID: 12345, + OTP: "654321", + Username: "telegramuser", + Password: pwdHash, + FirstName: "Telegram User", + InviteCode: "INVITE123", + } + accessRepo.invites["INVITE123"] = &models.InviteCode{ + Code: "INVITE123", + MaxUses: 2, + } + + // Wrong OTP + _, err = authSvc.ValidateOTP(ctx, models.ValidateOTPRequest{ + UserID: 12345, + OTP: "000000", + }) + if err == nil || err.Error() != "invalid OTP" { + t.Fatalf("expected 'invalid OTP', got %v", err) + } + + // Correct OTP + resp, err := authSvc.ValidateOTP(ctx, models.ValidateOTPRequest{ + UserID: 12345, + OTP: "654321", + }) + if err != nil { + t.Fatalf("ValidateOTP failed: %v", err) + } + if !resp.OK || resp.Token == "" { + t.Fatalf("expected successful response with token, got %+v", resp) + } + if resp.UserID != 12345 { + t.Fatalf("expected user ID 12345, got %d", resp.UserID) + } + + // Check OTP doc was deleted after validation + if _, exists := userRepo.otps[12345]; exists { + t.Fatalf("expected registration OTP to be deleted after completion") + } + + // Check user was created in repository with registered_via + savedUser, err := userRepo.GetUserByID(ctx, 12345) + if err != nil || savedUser == nil { + t.Fatalf("expected saved user in repository") + } + if savedUser.Username != "telegramuser" { + t.Fatalf("expected username telegramuser, got %s", savedUser.Username) + } + if savedUser.RegisteredVia == nil { + t.Fatalf("expected RegisteredVia to be populated") + } + + // Check invite was consumed + if accessRepo.invites["INVITE123"].UsedCount != 1 { + t.Fatalf("expected invite used count 1, got %d", accessRepo.invites["INVITE123"].UsedCount) + } +} + +func TestRegistrationPolicies(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{ + SecretKey: "supersecretkey123", + } + userRepo := newMockUserRepo() + accessRepo := newMockAccessRepoForAuth() + authSvc := services.NewAuthService(cfg, userRepo) + authSvc.SetAccessControlRepository(accessRepo) + + // 1. Closed mode + accessRepo.policy.RegistrationMode = "closed" + _, err := authSvc.RegisterAccount(ctx, models.RegisterRequest{ + Username: "closeduser", + Password: "password123", + }) + if err == nil || err.Error() != "registration_closed: New accounts cannot be created at this time" { + t.Fatalf("expected registration_closed error, got %v", err) + } + + // 2. Allowlist mode + accessRepo.policy.RegistrationMode = "allowlist" + _, err = authSvc.RegisterAccount(ctx, models.RegisterRequest{ + UserID: 99999, + Username: "allowlistuser", + Password: "password123", + }) + if err == nil || err.Error() != "not_allowlisted: Your account is not on the registration allowlist" { + t.Fatalf("expected not_allowlisted error, got %v", err) + } + + // Add to allowlist and retry (UserID <= 0 for direct creation) + accessRepo.allowlist[0] = true + resp, err := authSvc.RegisterAccount(ctx, models.RegisterRequest{ + UserID: 0, + Username: "allowlistuser", + Password: "password123", + }) + if err != nil { + t.Fatalf("expected success for allowlisted user, got %v", err) + } + if resp.UserID <= 0 { + t.Fatalf("expected positive user ID, got %d", resp.UserID) + } + + // 3. Invite mode + accessRepo.policy.RegistrationMode = "invite" + _, err = authSvc.RegisterAccount(ctx, models.RegisterRequest{ + Username: "inviteuser", + Password: "password123", + }) + if err == nil || err.Error() != "invite_required: An invite code is required to register" { + t.Fatalf("expected invite_required error, got %v", err) + } + + _, err = authSvc.RegisterAccount(ctx, models.RegisterRequest{ + Username: "inviteuser", + Password: "password123", + InviteCode: "INVALIDCODE", + }) + if err == nil || err.Error() != "invite_invalid: That invite code is invalid or has been revoked" { + t.Fatalf("expected invite_invalid error, got %v", err) + } + + accessRepo.invites["VALIDCODE"] = &models.InviteCode{ + Code: "VALIDCODE", + MaxUses: 1, + } + resp, err = authSvc.RegisterAccount(ctx, models.RegisterRequest{ + Username: "inviteuser", + Password: "password123", + InviteCode: "VALIDCODE", + }) + if err != nil { + t.Fatalf("expected success with valid invite code, got %v", err) + } + if accessRepo.invites["VALIDCODE"].UsedCount != 1 { + t.Fatalf("expected invite code used count 1, got %d", accessRepo.invites["VALIDCODE"].UsedCount) + } +} + +func TestBotSessionAndFCMToken(t *testing.T) { + ctx := context.Background() + userRepo := newMockUserRepo() + cfg := &config.Config{ + BotToken: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + SecretKey: "supersecretkey123", + } + authSvc := services.NewAuthService(cfg, userRepo) + + // 1. Create Bot Session + res, err := authSvc.CreateBotSession(ctx, "testinvite") + if err != nil { + t.Fatalf("CreateBotSession failed: %v", err) + } + sessionID, ok := res["session_id"].(string) + if !ok || sessionID == "" || !strings.HasPrefix(sessionID, "auth_") { + t.Fatalf("unexpected session_id: %v", res["session_id"]) + } + + // 2. Check pending status + st, err := authSvc.CheckBotSessionStatus(ctx, sessionID) + if err != nil { + t.Fatalf("CheckBotSessionStatus failed: %v", err) + } + if st["status"] != "pending" { + t.Fatalf("expected status pending, got %v", st["status"]) + } + + // 3. Check not found status + stNotFound, err := authSvc.CheckBotSessionStatus(ctx, "nonexistent") + if err != nil { + t.Fatalf("CheckBotSessionStatus failed: %v", err) + } + if stNotFound["status"] != "not_found" { + t.Fatalf("expected status not_found, got %v", stNotFound["status"]) + } + + // 4. Update session to confirmed + sess := userRepo.sessions[sessionID] + sess.Status = "confirmed" + sess.Token = "test_token_123" + sess.UserID = 999 + sess.Username = "confirmed_user" + + stConfirmed, err := authSvc.CheckBotSessionStatus(ctx, sessionID) + if err != nil { + t.Fatalf("CheckBotSessionStatus failed: %v", err) + } + if stConfirmed["status"] != "confirmed" || stConfirmed["token"] != "test_token_123" || stConfirmed["user_id"] != int64(999) { + t.Fatalf("unexpected confirmed status payload: %+v", stConfirmed) + } + + // 5. Update FCM Token + err = authSvc.UpdateFCMToken(ctx, 999, "fcm_token_sample_abc") + if err != nil { + t.Fatalf("UpdateFCMToken failed: %v", err) + } + if userRepo.fcmTokens[999] != "fcm_token_sample_abc" { + t.Fatalf("expected fcm token stored, got %v", userRepo.fcmTokens[999]) + } +} + diff --git a/internal/services/daily_playlist_service.go b/internal/services/daily_playlist_service.go new file mode 100644 index 0000000..e026fbc --- /dev/null +++ b/internal/services/daily_playlist_service.go @@ -0,0 +1,196 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// DailyPlaylistService coordinates daily dynamic playlist generation. +type DailyPlaylistService struct { + dailyRepo repository.DailyPlaylistRepository + trackRepo repository.TrackRepository +} + +// NewDailyPlaylistService creates a new DailyPlaylistService. +func NewDailyPlaylistService(dailyRepo repository.DailyPlaylistRepository, trackRepo repository.TrackRepository) *DailyPlaylistService { + return &DailyPlaylistService{ + dailyRepo: dailyRepo, + trackRepo: trackRepo, + } +} + +// CanonDailyKey normalizes daily playlist key names. +func CanonDailyKey(raw string) string { + k := strings.ToLower(strings.TrimSpace(raw)) + switch k { + case "random", "mix", "daily-playlist": + return "random" + case "trending", "trending-today", "top", "top-played", "top-playlist": + return "trending" + case "rediscover": + return "rediscover" + case "late-night", "late-night-mix": + return "late-night" + case "rising", "rising-tracks": + return "rising" + case "surprise", "surprise-me": + return "surprise" + default: + return k + } +} + +// GetAvailablePlaylists returns the list of daily playlists for the home page. +func (s *DailyPlaylistService) GetAvailablePlaylists(ctx context.Context) (*models.AvailablePlaylistsResponse, error) { + dailyDefs := []struct { + Key string + Name string + }{ + {"random", "Daily Mix"}, + {"trending", "Trending Today"}, + {"rediscover", "Rediscover"}, + {"late-night", "Late Night Mix"}, + {"rising", "Rising Tracks"}, + {"surprise", "Surprise Me"}, + } + + today := time.Now().UTC().Format("2006-01-02") + var items []*models.AvailablePlaylistItem + + for _, d := range dailyDefs { + docID := fmt.Sprintf("%s:%s:0", d.Key, today) + var thumbs []string + var coverURL string + + // If cached, grab cover from first tracks + if cached, _ := s.dailyRepo.GetCachedDailyPlaylist(ctx, docID); cached != nil && len(cached.TrackIDs) > 0 { + sampleIDs := cached.TrackIDs + if len(sampleIDs) > 4 { + sampleIDs = sampleIDs[:4] + } + if sampleTracks, err := s.trackRepo.GetByIDs(ctx, sampleIDs); err == nil { + for _, t := range sampleTracks { + u := t.EffectiveCoverURL() + if u != "" { + thumbs = append(thumbs, u) + } + } + if len(thumbs) > 0 { + coverURL = thumbs[0] + } + } + } + + items = append(items, &models.AvailablePlaylistItem{ + ID: fmt.Sprintf("daily:%s", d.Key), + Kind: "daily", + Name: d.Name, + Thumbnails: thumbs, + ThumbnailURL: coverURL, + NormalThumbnail: coverURL, + Endpoint: fmt.Sprintf("/daily-playlist/%s", d.Key), + RequiresAuth: false, + }) + } + + return &models.AvailablePlaylistsResponse{Items: items}, nil +} + +// GetDailyPlaylist returns the track list for a daily playlist key. +func (s *DailyPlaylistService) GetDailyPlaylist(ctx context.Context, rawKey string, limit int, channelID int64, userID int64) (*models.BrowseResponse, error) { + key := CanonDailyKey(rawKey) + if limit <= 0 { + limit = 75 + } + if limit > 100 { + limit = 100 + } + + today := time.Now().UTC().Format("2006-01-02") + docID := fmt.Sprintf("%s:%s:%d", key, today, channelID) + + // 1. Check cache + cached, err := s.dailyRepo.GetCachedDailyPlaylist(ctx, docID) + if err == nil && cached != nil && len(cached.TrackIDs) > 0 { + tracks, err := s.trackRepo.GetByIDs(ctx, cached.TrackIDs) + if err == nil && len(tracks) > 0 { + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + var items []*models.BrowseItem + for _, tid := range cached.TrackIDs { + if t, ok := trackMap[tid]; ok { + items = append(items, t.ToBrowseItem()) + } + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil + } + } + + // 2. Generate playlist + var tracks []*models.Track + switch key { + case "trending": + // Highest play count + tracks, _, err = s.trackRepo.List(ctx, 1, limit, "play_count", "", channelID) + case "rising": + // Recently added tracks + tracks, _, err = s.trackRepo.List(ctx, 1, limit, "created_at", "", channelID) + case "late-night": + // Low tempo / chill + tracks, _, err = s.trackRepo.List(ctx, 1, limit, "updated_at", "", channelID) + default: + // Random / Mix / Surprise + tracks, err = s.trackRepo.Random(ctx, limit, channelID) + } + + if err != nil { + return nil, err + } + + var trackIDs []string + var items []*models.BrowseItem + for _, t := range tracks { + trackIDs = append(trackIDs, t.ID) + items = append(items, t.ToBrowseItem()) + } + + // 3. Cache the generated playlist + if len(trackIDs) > 0 { + var firstCover string + if len(items) > 0 { + firstCover = items[0].CoverURL + } + doc := &models.DailyPlaylistDoc{ + ID: docID, + Key: key, + Date: today, + ChannelID: channelID, + TrackIDs: trackIDs, + ThumbnailURL: firstCover, + NormalThumbnail: firstCover, + GeneratedAt: float64(time.Now().Unix()), + } + _ = s.dailyRepo.SaveDailyPlaylist(ctx, doc) + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil +} diff --git a/internal/services/dedup_service.go b/internal/services/dedup_service.go index 3d20689..2512833 100644 --- a/internal/services/dedup_service.go +++ b/internal/services/dedup_service.go @@ -29,10 +29,10 @@ func NewDedupService(db *database.Client) *DedupService { return &DedupService{col: col} } -// NormalizeText cleans and strips non-alphanumeric characters for fingerprinting. +// NormalizeText cleans and strips non-alphanumeric characters for fingerprinting (supports multilingual Unicode). func NormalizeText(text string) string { s := strings.ToLower(text) - reNonAlpha := regexp.MustCompile(`[^a-z0-9]+`) + reNonAlpha := regexp.MustCompile(`[^\p{L}\p{N}]+`) s = reNonAlpha.ReplaceAllString(s, " ") return strings.TrimSpace(s) } diff --git a/internal/services/discord_service.go b/internal/services/discord_service.go new file mode 100644 index 0000000..0f2a277 --- /dev/null +++ b/internal/services/discord_service.go @@ -0,0 +1,583 @@ +package services + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + + "streamgo/internal/logger" + "streamgo/internal/models" +) + +var logDiscord = logger.New("discord_ra") + +const ( + DiscordGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2" + DiscordLoginURL = "https://discord.com/api/v9/users/@me/remote-auth/login" + SessionTTLSec = 240 +) + +// DiscordRemoteAuthSession holds ephemeral state for one QR code login session. +type DiscordRemoteAuthSession struct { + SessionID string + PrivKey *rsa.PrivateKey + PubKeyB64 string + + Status string + Fingerprint string + QRURL string + UserInfo map[string]any + Token string + Error string + CreatedAt float64 + + EventsChan chan map[string]any + Cancelled bool + CancelFunc context.CancelFunc + mu sync.RWMutex +} + +// DiscordService manages Discord Remote Auth sessions and external assets proxying. +type DiscordService struct { + sessions map[string]*DiscordRemoteAuthSession + mu sync.RWMutex +} + +// NewDiscordService creates a DiscordService. +func NewDiscordService() *DiscordService { + s := &DiscordService{ + sessions: make(map[string]*DiscordRemoteAuthSession), + } + go s.cleanupLoop() + return s +} + +func (s *DiscordService) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.cleanupStale() + } +} + +func (s *DiscordService) cleanupStale() { + s.mu.Lock() + defer s.mu.Unlock() + now := float64(time.Now().Unix()) + for id, sess := range s.sessions { + if now-sess.CreatedAt > SessionTTLSec { + sess.Cancel() + delete(s.sessions, id) + } + } +} + +// StartSession creates a new Remote Auth session and kicks off the gateway listener in background. +func (s *DiscordService) StartSession(ctx context.Context) (*models.StartRemoteAuthResponse, error) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + spkiDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + pubKeyB64 := base64.StdEncoding.EncodeToString(spkiDER) + + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + sessionID := fmt.Sprintf("%x-%x-%x-%x-%x", idBytes[0:4], idBytes[4:6], idBytes[6:8], idBytes[8:10], idBytes[10:16]) + + sessCtx, cancel := context.WithCancel(context.Background()) + sess := &DiscordRemoteAuthSession{ + SessionID: sessionID, + PrivKey: privKey, + PubKeyB64: pubKeyB64, + Status: "init", + CreatedAt: float64(time.Now().Unix()), + EventsChan: make(chan map[string]any, 64), + CancelFunc: cancel, + } + + s.mu.Lock() + s.sessions[sessionID] = sess + s.mu.Unlock() + + go sess.Run(sessCtx) + + // Wait up to 4 seconds for initial QR fingerprint to appear + timeout := time.After(4 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-timeout: + return &models.StartRemoteAuthResponse{ + SessionID: sessionID, + Status: sess.GetStatus(), + }, nil + case <-ticker.C: + sess.mu.RLock() + fp := sess.Fingerprint + qr := sess.QRURL + status := sess.Status + errMsg := sess.Error + sess.mu.RUnlock() + + if errMsg != "" { + return nil, errors.New(errMsg) + } + if fp != "" { + return &models.StartRemoteAuthResponse{ + SessionID: sessionID, + Status: status, + URL: qr, + Fingerprint: fp, + }, nil + } + } + } +} + +// CreateWSSession starts a remote auth session for direct WebSocket streaming. +func (s *DiscordService) CreateWSSession(ctx context.Context) (*DiscordRemoteAuthSession, error) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + spkiDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + pubKeyB64 := base64.StdEncoding.EncodeToString(spkiDER) + + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + sessionID := fmt.Sprintf("%x-%x-%x-%x-%x", idBytes[0:4], idBytes[4:6], idBytes[6:8], idBytes[8:10], idBytes[10:16]) + + sessCtx, cancel := context.WithCancel(ctx) + sess := &DiscordRemoteAuthSession{ + SessionID: sessionID, + PrivKey: privKey, + PubKeyB64: pubKeyB64, + Status: "init", + CreatedAt: float64(time.Now().Unix()), + EventsChan: make(chan map[string]any, 64), + CancelFunc: cancel, + } + + s.mu.Lock() + s.sessions[sessionID] = sess + s.mu.Unlock() + + go sess.Run(sessCtx) + return sess, nil +} + +// GetStatus returns the current status and user/token if completed. +func (s *DiscordService) GetStatus(sessionID string) (*models.RemoteAuthStatusResponse, error) { + s.mu.RLock() + sess, ok := s.sessions[sessionID] + s.mu.RUnlock() + if !ok { + return nil, errors.New("session not found or expired") + } + + sess.mu.RLock() + defer sess.mu.RUnlock() + + return &models.RemoteAuthStatusResponse{ + SessionID: sess.SessionID, + Status: sess.Status, + URL: sess.QRURL, + Fingerprint: sess.Fingerprint, + User: sess.UserInfo, + Token: sess.Token, + Error: sess.Error, + }, nil +} + +// CancelSession terminates an active session. +func (s *DiscordService) CancelSession(sessionID string) error { + s.mu.Lock() + sess, ok := s.sessions[sessionID] + if ok { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if ok && sess != nil { + sess.Cancel() + } + return nil +} + +// GetSession retrieves session by ID. +func (s *DiscordService) GetSession(sessionID string) *DiscordRemoteAuthSession { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sessions[sessionID] +} + +// Cancel terminates the session context. +func (sess *DiscordRemoteAuthSession) Cancel() { + sess.mu.Lock() + sess.Cancelled = true + sess.Status = "cancelled" + sess.mu.Unlock() + if sess.CancelFunc != nil { + sess.CancelFunc() + } +} + +// GetStatus gets status safely. +func (sess *DiscordRemoteAuthSession) GetStatus() string { + sess.mu.RLock() + defer sess.mu.RUnlock() + return sess.Status +} + +func (sess *DiscordRemoteAuthSession) decryptOAEP(encB64 string) ([]byte, error) { + cipherBytes, err := base64.StdEncoding.DecodeString(encB64) + if err != nil { + return nil, err + } + return rsa.DecryptOAEP(sha256.New(), rand.Reader, sess.PrivKey, cipherBytes, nil) +} + +func (sess *DiscordRemoteAuthSession) emit(event map[string]any) { + select { + case sess.EventsChan <- event: + default: + } +} + +// Run manages the WebSocket connection to Discord's Remote Auth Gateway. +func (sess *DiscordRemoteAuthSession) Run(ctx context.Context) { + defer func() { + close(sess.EventsChan) + }() + + header := make(http.Header) + header.Set("Origin", "https://discord.com") + header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") + + conn, _, err := websocket.Dial(ctx, DiscordGatewayURL, &websocket.DialOptions{ + HTTPHeader: header, + }) + if err != nil { + sess.mu.Lock() + sess.Error = err.Error() + sess.Status = "error" + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": err.Error()}) + return + } + defer conn.Close(websocket.StatusNormalClosure, "done") + + sess.emit(map[string]any{"type": "status", "status": "connecting", "session_id": sess.SessionID}) + + var heartbeatCancel context.CancelFunc + + for { + select { + case <-ctx.Done(): + if heartbeatCancel != nil { + heartbeatCancel() + } + return + default: + } + + _, data, err := conn.Read(ctx) + if err != nil { + if !sess.Cancelled { + sess.mu.Lock() + sess.Status = "error" + sess.Error = err.Error() + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": err.Error()}) + } + if heartbeatCancel != nil { + heartbeatCancel() + } + return + } + + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + continue + } + + op, _ := payload["op"].(string) + + switch op { + case "hello": + intervalMs := 41250.0 + if v, ok := payload["heartbeat_interval"].(float64); ok && v > 0 { + intervalMs = v + } + hbCtx, hbCancel := context.WithCancel(ctx) + heartbeatCancel = hbCancel + go sess.heartbeatLoop(hbCtx, conn, intervalMs) + + initMsg := map[string]any{ + "op": "init", + "encoded_public_key": sess.PubKeyB64, + } + initBytes, _ := json.Marshal(initMsg) + _ = conn.Write(ctx, websocket.MessageText, initBytes) + + case "nonce_proof": + encNonce, _ := payload["encrypted_nonce"].(string) + decrypted, err := sess.decryptOAEP(encNonce) + if err != nil { + sess.mu.Lock() + sess.Status = "error" + sess.Error = "nonce proof failed: " + err.Error() + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": sess.Error}) + return + } + digest := sha256.Sum256(decrypted) + proof := base64.RawURLEncoding.EncodeToString(digest[:]) + proofMsg := map[string]any{ + "op": "nonce_proof", + "proof": proof, + } + proofBytes, _ := json.Marshal(proofMsg) + _ = conn.Write(ctx, websocket.MessageText, proofBytes) + + case "fingerprint", "pending_remote_init": + fp, _ := payload["fingerprint"].(string) + if fp != "" && sess.Fingerprint == "" { + sess.mu.Lock() + sess.Fingerprint = fp + sess.QRURL = fmt.Sprintf("https://discord.com/ra/%s", fp) + sess.Status = "waiting_scan" + sess.mu.Unlock() + + sess.emit(map[string]any{ + "type": "qr", + "url": sess.QRURL, + "fingerprint": fp, + "session_id": sess.SessionID, + }) + } + + if userMap, ok := payload["user"].(map[string]any); ok && userMap != nil { + sess.mu.Lock() + sess.UserInfo = userMap + sess.Status = "scanned" + sess.mu.Unlock() + + sess.emit(map[string]any{ + "type": "scanned", + "user": userMap, + "session_id": sess.SessionID, + }) + } + + case "pending_ticket": + encUser, _ := payload["encrypted_user_payload"].(string) + if encUser != "" { + if decUser, err := sess.decryptOAEP(encUser); err == nil { + parts := strings.Split(string(decUser), ":") + uID := "" + uDisc := "0" + uAv := "" + uName := "Discord User" + if len(parts) > 0 { + uID = parts[0] + } + if len(parts) > 1 { + uDisc = parts[1] + } + if len(parts) > 2 && parts[2] != "None" { + uAv = parts[2] + } + if len(parts) > 3 { + uName = parts[3] + } + sess.mu.Lock() + sess.UserInfo = map[string]any{ + "id": uID, + "username": uName, + "discriminator": uDisc, + "avatar": uAv, + } + sess.Status = "scanned" + sess.mu.Unlock() + } + } + sess.emit(map[string]any{ + "type": "scanned", + "user": sess.UserInfo, + "session_id": sess.SessionID, + }) + + case "pending_login": + ticket, _ := payload["ticket"].(string) + if ticket == "" { + continue + } + + sess.mu.Lock() + sess.Status = "confirming" + sess.mu.Unlock() + sess.emit(map[string]any{"type": "status", "status": "confirming", "session_id": sess.SessionID}) + + // Exchange ticket with Discord REST API + token, err := sess.exchangeTicket(ctx, ticket) + if err != nil { + sess.mu.Lock() + sess.Status = "error" + sess.Error = err.Error() + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": err.Error()}) + return + } + + sess.mu.Lock() + sess.Token = token + sess.Status = "success" + sess.mu.Unlock() + + sess.emit(map[string]any{ + "type": "success", + "token": token, + "user": sess.UserInfo, + "session_id": sess.SessionID, + }) + + // Graceful cancel after completion + cancelMsg, _ := json.Marshal(map[string]any{"op": "cancel"}) + _ = conn.Write(ctx, websocket.MessageText, cancelMsg) + return + + case "cancel": + sess.mu.Lock() + sess.Status = "cancelled" + sess.mu.Unlock() + sess.emit(map[string]any{"type": "cancelled", "session_id": sess.SessionID}) + return + } + } +} + +func (sess *DiscordRemoteAuthSession) heartbeatLoop(ctx context.Context, conn *websocket.Conn, intervalMs float64) { + intervalSec := (intervalMs / 1000.0) * 0.9 + if intervalSec < 5.0 { + intervalSec = 5.0 + } + ticker := time.NewTicker(time.Duration(intervalSec * float64(time.Second))) + defer ticker.Stop() + + hbMsg, _ := json.Marshal(map[string]any{"op": "heartbeat"}) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := conn.Write(ctx, websocket.MessageText, hbMsg); err != nil { + return + } + } + } +} + +func (sess *DiscordRemoteAuthSession) exchangeTicket(ctx context.Context, ticket string) (string, error) { + body, _ := json.Marshal(map[string]any{"ticket": ticket}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, DiscordLoginURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("ticket exchange returned HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var data struct { + EncryptedToken string `json:"encrypted_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + if data.EncryptedToken == "" { + return "", errors.New("missing encrypted_token in response") + } + + decryptedToken, err := sess.decryptOAEP(data.EncryptedToken) + if err != nil { + return "", fmt.Errorf("failed to decrypt user token: %w", err) + } + + return string(decryptedToken), nil +} + +// ResolveExternalAssets proxies external asset requests to Discord API. +func (s *DiscordService) ResolveExternalAssets(ctx context.Context, req models.ExternalAssetsRequest) ([]map[string]any, error) { + appID := req.ApplicationID + if appID == "" { + appID = "1547543416143876167" + } + token := strings.TrimSpace(req.Token) + if token == "" || len(req.URLs) == 0 { + return []map[string]any{}, nil + } + + discordURL := fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", appID) + body, _ := json.Marshal(map[string]any{"urls": req.URLs}) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, discordURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return []map[string]any{}, nil + } + + var results []map[string]any + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, err + } + return results, nil +} diff --git a/internal/services/discord_test.go b/internal/services/discord_test.go new file mode 100644 index 0000000..dcfc453 --- /dev/null +++ b/internal/services/discord_test.go @@ -0,0 +1,86 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "testing" + "time" + + "streamgo/internal/models" +) + +func TestDiscordSessionLifecycle(t *testing.T) { + svc := NewDiscordService() + ctx := context.Background() + + sess, err := svc.CreateWSSession(ctx) + if err != nil { + t.Fatalf("failed to create WS session: %v", err) + } + + if sess.SessionID == "" { + t.Fatalf("expected non-empty session ID") + } + if sess.PrivKey == nil { + t.Fatalf("expected non-nil RSA private key") + } + if sess.PubKeyB64 == "" { + t.Fatalf("expected non-empty public key string") + } + + // Verify session is discoverable in service + status, err := svc.GetStatus(sess.SessionID) + if err != nil { + t.Fatalf("failed to get session status: %v", err) + } + if status.SessionID != sess.SessionID { + t.Errorf("expected session ID %s, got %s", sess.SessionID, status.SessionID) + } + + // Test OAEP decryption with session's private key + testMsg := "test-secret-nonce-or-token" + encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &sess.PrivKey.PublicKey, []byte(testMsg), nil) + if err != nil { + t.Fatalf("failed to encrypt OAEP: %v", err) + } + encB64 := base64.StdEncoding.EncodeToString(encrypted) + + decrypted, err := sess.decryptOAEP(encB64) + if err != nil { + t.Fatalf("failed to decrypt OAEP: %v", err) + } + if string(decrypted) != testMsg { + t.Errorf("expected decrypted %s, got %s", testMsg, string(decrypted)) + } + + // Test cancellation + err = svc.CancelSession(sess.SessionID) + if err != nil { + t.Fatalf("failed to cancel session: %v", err) + } + + time.Sleep(10 * time.Millisecond) + if !sess.Cancelled { + t.Errorf("expected session to be marked as cancelled") + } +} + +func TestResolveExternalAssetsEmpty(t *testing.T) { + svc := NewDiscordService() + ctx := context.Background() + + // Empty URLs or token should return empty slice without error + res, err := svc.ResolveExternalAssets(ctx, models.ExternalAssetsRequest{ + URLs: []string{}, + Token: "", + }) + if err != nil { + t.Fatalf("unexpected error on empty request: %v", err) + } + if len(res) != 0 { + t.Errorf("expected 0 results, got %d", len(res)) + } +} diff --git a/internal/services/enrichment_service.go b/internal/services/enrichment_service.go index d8c985a..b2f9c34 100644 --- a/internal/services/enrichment_service.go +++ b/internal/services/enrichment_service.go @@ -20,9 +20,10 @@ import ( var logEnrich = logger.New("enrichment") -// MediaDownloader downloads partial media bytes for an indexed file_id. +// MediaDownloader downloads partial media bytes for an indexed file_id or track. type MediaDownloader interface { DownloadPartialByFileID(ctx context.Context, fileID string, maxBytes int64, w io.Writer) error + DownloadPartialForTrack(ctx context.Context, track *models.Track, maxBytes int64, w io.Writer, onRefreshed func(botID, fileID string)) error } // EnrichmentService runs background worker goroutines to enrich track metadata. @@ -161,6 +162,7 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin artist := track.EffectiveArtist() album := track.Audio.Album durationSec := track.Audio.DurationSec + year := track.Audio.Year nowTs := float64(time.Now().Unix()) updateFields := bson.M{ @@ -170,11 +172,18 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin } // 1. Partial Media Download & MediaInfo extraction (matching Python StreamXBot) - if s.downloader != nil && track.Telegram.FileID != "" { + if s.downloader != nil { tmpFile, err := os.CreateTemp("", fmt.Sprintf("streamgo_mi_%s_*.part", track.ID)) if err == nil { tmpPath := tmpFile.Name() - dlErr := s.downloader.DownloadPartialByFileID(ctx, track.Telegram.FileID, 2_000_000, tmpFile) + dlErr := s.downloader.DownloadPartialForTrack(ctx, &track, 2_000_000, tmpFile, func(botID, fileID string) { + _ = s.tracksCol.FindOneAndUpdate(ctx, bson.M{"_id": trackID}, bson.M{ + "$set": bson.M{ + fmt.Sprintf("telegram.file_ids.%s", botID): fileID, + "updated_at": float64(time.Now().Unix()), + }, + }) + }) tmpFile.Close() defer os.Remove(tmpPath) @@ -213,6 +222,7 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin updateFields["audio.genre"] = meta.Genre } if meta.Year != nil { + year = meta.Year updateFields["audio.year"] = *meta.Year } if meta.DurationSec > 0 { @@ -233,6 +243,8 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin } if meta.AlbumID != "" { updateFields["audio.album_id"] = meta.AlbumID + } else if album != "" { + updateFields["audio.album_id"] = GenerateAlbumID(album, year) } } } else if err != nil { @@ -256,7 +268,7 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin // 3. Ensure album_id is set if album is present if album != "" && updateFields["audio.album_id"] == nil && track.Audio.AlbumID == "" { - aid := GenerateAlbumID(album, nil) + aid := GenerateAlbumID(album, year) if aid != "" { updateFields["audio.album_id"] = aid } @@ -280,30 +292,48 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin } } - // 6. Fetch lyrics if missing (stores in root lyrics & lyrics_cache, NEVER in audio) - if track.Lyrics == "" { + // 6. Fetch lyrics and multilingual/romanized titles + if s.lyricsSvc != nil { res, err := s.lyricsSvc.FetchLyrics(ctx, title, artist, album) - if err == nil && res != nil && res.Lyrics != "" { - updateFields["lyrics"] = res.Lyrics - updateFields["lyrics_cache"] = bson.M{ - "kind": "lrc", - "source": "lrclib", - "text": res.Lyrics, - "updated_at": nowTs, + if err == nil && res != nil { + if res.Lyrics != "" && (track.Lyrics == "" || track.LyricsCache == nil || track.LyricsCache["kind"] != "richsync") { + updateFields["lyrics"] = res.Lyrics + kind := res.Kind + if kind == "" { + kind = "lrc" + } + source := res.Source + if source == "" { + source = "musixmatch" + } + updateFields["lyrics_cache"] = bson.M{ + "kind": kind, + "source": source, + "text": res.Lyrics, + "updated_at": nowTs, + } + } + if len(res.Titles) > 0 { + updateFields["titles"] = res.Titles + updateFields["audio.titles"] = res.Titles } } } - // 7. Ensure root titles is populated - if len(track.Titles) == 0 { - updateFields["titles"] = bson.M{ - "original": title, + // 7. Ensure titles and audio.titles are populated + if updateFields["titles"] == nil { + if len(track.Titles) > 0 { + updateFields["audio.titles"] = track.Titles + } else { + updateFields["titles"] = bson.M{"original": title} + updateFields["audio.titles"] = bson.M{"original": title} } + } else if updateFields["audio.titles"] == nil { + updateFields["audio.titles"] = updateFields["titles"] } - // 8. Clean unsetting of legacy / erroneous fields inside audio subdocument + // 8. Clean unsetting of legacy / erroneous fields inside audio subdocument (preserving audio.titles!) unsetFields := bson.M{ - "audio.titles": "", "audio.lyrics": "", "audio.cover_url": "", "audio.file_size": "", diff --git a/internal/services/favourite_playlist_service.go b/internal/services/favourite_playlist_service.go index 9a2a7e5..f037407 100644 --- a/internal/services/favourite_playlist_service.go +++ b/internal/services/favourite_playlist_service.go @@ -2,198 +2,277 @@ package services import ( "context" + "crypto/rand" + "encoding/hex" "errors" - "math" + "fmt" "strings" "time" - "github.com/google/uuid" - "streamgo/internal/models" "streamgo/internal/repository" ) -// FavouritePlaylistService orchestrates user favorites and playlist logic. +// FavouritePlaylistService orchestrates user favourites, custom playlists, albums, and history. type FavouritePlaylistService struct { - favRepo repository.FavouritePlaylistRepository - trackRepo repository.TrackRepository + favRepo repository.FavouritePlaylistRepository + trackRepo repository.TrackRepository + artistAlbumRepo repository.ArtistAlbumRepository } // NewFavouritePlaylistService creates a new FavouritePlaylistService. -func NewFavouritePlaylistService(favRepo repository.FavouritePlaylistRepository, trackRepo repository.TrackRepository) *FavouritePlaylistService { +func NewFavouritePlaylistService( + favRepo repository.FavouritePlaylistRepository, + trackRepo repository.TrackRepository, + artistAlbumRepo repository.ArtistAlbumRepository, +) *FavouritePlaylistService { return &FavouritePlaylistService{ - favRepo: favRepo, - trackRepo: trackRepo, + favRepo: favRepo, + trackRepo: trackRepo, + artistAlbumRepo: artistAlbumRepo, } } -// AddFavourite likes a track for a user. -func (s *FavouritePlaylistService) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { - trackID = strings.TrimSpace(trackID) - if trackID == "" { - return false, errors.New("track_id is required") - } - track, err := s.trackRepo.GetByID(ctx, trackID) - if err != nil { - return false, err - } - if track == nil { - return false, errors.New("track not found") +// Helper to extract track cover URL +func extractTrackThumbnail(t *models.Track) string { + if t == nil { + return "" } - return s.favRepo.AddFavourite(ctx, userID, trackID) + return t.EffectiveCoverURL() } -// RemoveFavourite unlikes a track. -func (s *FavouritePlaylistService) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { - trackID = strings.TrimSpace(trackID) - if trackID == "" { - return false, errors.New("track_id is required") - } - return s.favRepo.RemoveFavourite(ctx, userID, trackID) -} +// Helper to assemble up to 4 thumbnail URLs for playlist collage preview +func buildPlaylistThumbnails(coverURL string, trackThumbs []string, limit int) []string { + var out []string + seen := make(map[string]bool) -// GetFavouriteIDs returns array of favorite track IDs. -func (s *FavouritePlaylistService) GetFavouriteIDs(ctx context.Context, userID int64, page, limit int) (*models.FavouriteIDsResponse, error) { - ids, _, _, err := s.favRepo.GetFavouriteTrackIDs(ctx, userID, page, limit) - if err != nil { - return nil, err + for _, u := range trackThumbs { + if len(out) >= limit { + break + } + u = strings.TrimSpace(u) + if u != "" && !seen[u] { + seen[u] = true + out = append(out, u) + } } - if ids == nil { - ids = []string{} + + if coverURL != "" && !seen[coverURL] && len(out) < limit { + seen[coverURL] = true + out = append(out, coverURL) } - return &models.FavouriteIDsResponse{ - OK: true, - IDs: ids, - }, nil + + return out } -// GetFavourites returns paginated favorites converted to BrowseItems. -func (s *FavouritePlaylistService) GetFavourites(ctx context.Context, userID int64, page, limit int) (*models.BrowseResponse, error) { - if page < 1 { - page = 1 +// CreatePlaylist creates a new user playlist with a generated UUID. +func (s *FavouritePlaylistService) CreatePlaylist(ctx context.Context, userID int64, name string) (*models.PlaylistItem, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name is required") } - if limit < 1 || limit > 100 { - limit = 20 + + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + playlistID := hex.EncodeToString(idBytes) + + now := float64(time.Now().Unix()) + coverID := fmt.Sprintf("cover_%s", playlistID) + coverURL := fmt.Sprintf("/covers/user-playlist/%s.png", playlistID) + normalThumbnail := coverURL + + p := &models.UserPlaylist{ + ID: playlistID, + UserID: userID, + Name: name, + CoverID: coverID, + CoverURL: coverURL, + NormalThumbnail: normalThumbnail, + CreatedAt: now, + UpdatedAt: now, } - ids, total, _, err := s.favRepo.GetFavouriteTrackIDs(ctx, userID, page, limit) - if err != nil { + if err := s.favRepo.CreatePlaylist(ctx, p); err != nil { return nil, err } - tracks, err := s.trackRepo.GetByIDs(ctx, ids) + return &models.PlaylistItem{ + PlaylistID: playlistID, + Name: name, + Thumbnails: buildPlaylistThumbnails(coverURL, nil, 4), + CoverID: coverID, + CoverURL: coverURL, + NormalThumbnail: normalThumbnail, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// GetUserPlaylists returns all playlists owned by user with up to 4 track cover thumbnails for collages. +func (s *FavouritePlaylistService) GetUserPlaylists(ctx context.Context, userID int64) (*models.PlaylistsResponse, error) { + rawPlaylists, err := s.favRepo.GetUserPlaylists(ctx, userID) if err != nil { return nil, err } - items := make([]*models.BrowseItem, 0, len(tracks)) - for _, t := range tracks { - items = append(items, t.ToBrowseItem()) + items := make([]models.PlaylistItem, 0, len(rawPlaylists)) + for _, p := range rawPlaylists { + firstIDs, _ := s.favRepo.GetPlaylistFirstTrackIDs(ctx, p.ID, 4) + var trackThumbs []string + if len(firstIDs) > 0 { + tracks, _ := s.trackRepo.GetByIDs(ctx, firstIDs) + for _, t := range tracks { + if thumb := extractTrackThumbnail(t); thumb != "" { + trackThumbs = append(trackThumbs, thumb) + } + } + } + + items = append(items, models.PlaylistItem{ + PlaylistID: p.ID, + Name: p.Name, + Thumbnails: buildPlaylistThumbnails(p.CoverURL, trackThumbs, 4), + CoverURL: p.CoverURL, + NormalThumbnail: p.NormalThumbnail, + CoverID: p.CoverID, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + }) + } + + return &models.PlaylistsResponse{Items: items}, nil +} + +// RenamePlaylist renames an existing user playlist. +func (s *FavouritePlaylistService) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name string) (*models.PlaylistItem, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name is required") } - totalPages := 0 - if total > 0 { - totalPages = int(math.Ceil(float64(total) / float64(limit))) + coverID := fmt.Sprintf("cover_%s", playlistID) + coverURL := fmt.Sprintf("/covers/user-playlist/%s.png", playlistID) + normalThumbnail := coverURL + + p, err := s.favRepo.RenamePlaylist(ctx, playlistID, userID, name, coverID, coverURL, normalThumbnail) + if err != nil { + return nil, err } - return &models.BrowseResponse{ - Items: items, - Total: total, - Page: page, - PerPage: limit, - TotalPages: totalPages, + firstIDs, _ := s.favRepo.GetPlaylistFirstTrackIDs(ctx, playlistID, 4) + var trackThumbs []string + if len(firstIDs) > 0 { + tracks, _ := s.trackRepo.GetByIDs(ctx, firstIDs) + for _, t := range tracks { + if thumb := extractTrackThumbnail(t); thumb != "" { + trackThumbs = append(trackThumbs, thumb) + } + } + } + + return &models.PlaylistItem{ + PlaylistID: p.ID, + Name: p.Name, + Thumbnails: buildPlaylistThumbnails(p.CoverURL, trackThumbs, 4), + CoverURL: p.CoverURL, + NormalThumbnail: p.NormalThumbnail, + CoverID: p.CoverID, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, }, nil } -// AddFavouriteArtist follows an artist. -func (s *FavouritePlaylistService) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { - artistID = strings.TrimSpace(artistID) - if artistID == "" { - return false, errors.New("artist_id is required") - } - return s.favRepo.AddFavouriteArtist(ctx, userID, artistID) +// DeletePlaylist deletes a playlist and cleans up its tracks. +func (s *FavouritePlaylistService) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { + return s.favRepo.DeletePlaylist(ctx, playlistID, userID) } -// RemoveFavouriteArtist unfollows an artist. -func (s *FavouritePlaylistService) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { - artistID = strings.TrimSpace(artistID) - if artistID == "" { - return false, errors.New("artist_id is required") +// AddTracksToPlaylist appends tracks to a playlist. +func (s *FavouritePlaylistService) AddTracksToPlaylist(ctx context.Context, playlistID string, userID int64, trackIDs []string) (int, error) { + existing, err := s.favRepo.GetPlaylistByID(ctx, playlistID, userID) + if err != nil { + return 0, err } - return s.favRepo.RemoveFavouriteArtist(ctx, userID, artistID) + if existing == nil { + return 0, errors.New("playlist not found") + } + + // Verify tracks exist + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil || len(tracks) == 0 { + return 0, errors.New("tracks not found") + } + + var validIDs []string + for _, t := range tracks { + validIDs = append(validIDs, t.ID) + } + + return s.favRepo.AddTracksToPlaylist(ctx, playlistID, validIDs) } -// GetFavouriteArtistIDs returns list of followed artist IDs. -func (s *FavouritePlaylistService) GetFavouriteArtistIDs(ctx context.Context, userID int64) (*models.FavouriteArtistsResponse, error) { - ids, err := s.favRepo.GetFavouriteArtistIDs(ctx, userID) +// RemoveTrackFromPlaylist removes a track from a playlist. +func (s *FavouritePlaylistService) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, userID int64, trackID string) error { + existing, err := s.favRepo.GetPlaylistByID(ctx, playlistID, userID) if err != nil { - return nil, err + return err } - if ids == nil { - ids = []string{} + if existing == nil { + return errors.New("playlist not found") } - return &models.FavouriteArtistsResponse{ - OK: true, - IDs: ids, - }, nil + + return s.favRepo.RemoveTrackFromPlaylist(ctx, playlistID, trackID) } -// CreatePlaylist creates a new custom playlist. -func (s *FavouritePlaylistService) CreatePlaylist(ctx context.Context, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) { - title = strings.TrimSpace(title) - if title == "" { - return nil, errors.New("playlist title is required") +// GetPlaylistTracks returns paginated tracks inside a playlist. +func (s *FavouritePlaylistService) GetPlaylistTracks(ctx context.Context, playlistID string, userID int64, page, limit int) (*models.PlaylistTracksResponse, error) { + existing, err := s.favRepo.GetPlaylistByID(ctx, playlistID, userID) + if err != nil { + return nil, err } - - p := &models.UserPlaylist{ - ID: uuid.New().String(), - UserID: userID, - Title: title, - Description: strings.TrimSpace(description), - CoverURL: strings.TrimSpace(coverURL), - TrackCount: 0, - CreatedAt: float64(time.Now().Unix()), - UpdatedAt: float64(time.Now().Unix()), + if existing == nil { + return nil, errors.New("playlist not found") } - if err := s.favRepo.CreatePlaylist(ctx, p); err != nil { + ids, total, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID, page, limit) + if err != nil { return nil, err } - return p, nil -} -// GetUserPlaylists lists all playlists owned by a user. -func (s *FavouritePlaylistService) GetUserPlaylists(ctx context.Context, userID int64) (*models.PlaylistsResponse, error) { - playlists, err := s.favRepo.GetUserPlaylists(ctx, userID) + tracks, err := s.trackRepo.GetByIDs(ctx, ids) if err != nil { return nil, err } - if playlists == nil { - playlists = []*models.UserPlaylist{} + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) } - return &models.PlaylistsResponse{ - OK: true, - Total: len(playlists), - Items: playlists, + + return &models.PlaylistTracksResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, }, nil } -// GetPlaylist retrieves a playlist along with its items. -func (s *FavouritePlaylistService) GetPlaylist(ctx context.Context, playlistID string) (*models.PlaylistDetail, error) { - playlist, err := s.favRepo.GetPlaylistByID(ctx, playlistID) +// GetSharedPlaylist returns public read-only playlist with tracks for sharing. +func (s *FavouritePlaylistService) GetSharedPlaylist(ctx context.Context, playlistID string) (*models.PlaylistShareResponse, error) { + p, err := s.favRepo.GetPublicPlaylistByID(ctx, playlistID) if err != nil { return nil, err } - if playlist == nil { - return nil, nil + if p == nil { + return nil, errors.New("playlist not found") } - trackIDs, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID) + ids, _, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID, 1, 200) if err != nil { return nil, err } - tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + tracks, err := s.trackRepo.GetByIDs(ctx, ids) if err != nil { return nil, err } @@ -203,92 +282,228 @@ func (s *FavouritePlaylistService) GetPlaylist(ctx context.Context, playlistID s items = append(items, t.ToBrowseItem()) } - return &models.PlaylistDetail{ - Playlist: playlist, - Tracks: items, + return &models.PlaylistShareResponse{ + PlaylistID: p.ID, + Name: p.Name, + CoverURL: p.CoverURL, + NormalThumbnail: p.NormalThumbnail, + Tracks: items, + CreatedAt: p.CreatedAt, }, nil } -// UpdatePlaylist updates metadata of a user playlist. -func (s *FavouritePlaylistService) UpdatePlaylist(ctx context.Context, playlistID string, userID int64, title, description, coverURL string) (*models.UserPlaylist, error) { - return s.favRepo.UpdatePlaylist(ctx, playlistID, userID, title, description, coverURL) -} - -// DeletePlaylist removes a playlist and its track relations. -func (s *FavouritePlaylistService) DeletePlaylist(ctx context.Context, playlistID string, userID int64) (bool, error) { - return s.favRepo.DeletePlaylist(ctx, playlistID, userID) +// AddFavourite likes a track. +func (s *FavouritePlaylistService) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return false, errors.New("track_id is required") + } + track, err := s.trackRepo.GetByID(ctx, trackID) + if err != nil { + return false, err + } + if track == nil { + return false, errors.New("track not found") + } + return s.favRepo.AddFavourite(ctx, userID, trackID) } -// AddTracksToPlaylist adds multiple tracks to an existing playlist. -func (s *FavouritePlaylistService) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error { - return s.favRepo.AddTracksToPlaylist(ctx, playlistID, trackIDs) +// RemoveFavourite unlikes a track. +func (s *FavouritePlaylistService) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return false, errors.New("track_id is required") + } + return s.favRepo.RemoveFavourite(ctx, userID, trackID) } -// RemoveTrackFromPlaylist removes a single track from a playlist. -func (s *FavouritePlaylistService) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { - return s.favRepo.RemoveTrackFromPlaylist(ctx, playlistID, trackID) -} +// GetFavourites returns paginated favorites with track details. +func (s *FavouritePlaylistService) GetFavourites(ctx context.Context, userID int64, page, limit int) (*models.FavouritesResponse, error) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } -// GetPlaylistTracks returns the ordered tracks inside a playlist. -func (s *FavouritePlaylistService) GetPlaylistTracks(ctx context.Context, playlistID string) ([]*models.BrowseItem, error) { - trackIDs, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID) + ids, total, lastTS, err := s.favRepo.GetFavouriteTrackIDs(ctx, userID, page, limit) if err != nil { return nil, err } - tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + tracks, err := s.trackRepo.GetByIDs(ctx, ids) if err != nil { return nil, err } - items := make([]*models.BrowseItem, 0, len(tracks)) + byID := make(map[string]*models.Track) for _, t := range tracks { - items = append(items, t.ToBrowseItem()) + byID[t.ID] = t } - return items, nil + + items := make([]models.FavouriteItem, 0, len(ids)) + for _, id := range ids { + if t, ok := byID[id]; ok { + items = append(items, models.FavouriteItem{ + Track: t.ToBrowseItem(), + }) + } + } + + return &models.FavouritesResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + LastUpdatedAt: lastTS, + }, nil +} + +// GetFavouriteIDs returns lightweight list of liked track IDs. +func (s *FavouritePlaylistService) GetFavouriteIDs(ctx context.Context, userID int64, page, limit int) (*models.FavouriteIdsResponse, error) { + ids, total, lastTS, err := s.favRepo.GetAllFavouriteTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, err + } + if ids == nil { + ids = []string{} + } + return &models.FavouriteIdsResponse{ + Page: page, + PerPage: limit, + Total: total, + IDs: ids, + Exists: total > 0, + LastUpdatedAt: lastTS, + }, nil +} + +// AddFavouriteArtist follows an artist. +func (s *FavouritePlaylistService) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + artistID = strings.TrimSpace(artistID) + if artistID == "" { + return false, errors.New("artist_id is required") + } + return s.favRepo.AddFavouriteArtist(ctx, userID, artistID) +} + +// RemoveFavouriteArtist unfollows an artist. +func (s *FavouritePlaylistService) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + artistID = strings.TrimSpace(artistID) + if artistID == "" { + return false, errors.New("artist_id is required") + } + return s.favRepo.RemoveFavouriteArtist(ctx, userID, artistID) } -// ReorderPlaylistTracks updates the order of tracks in a playlist. -func (s *FavouritePlaylistService) ReorderPlaylistTracks(ctx context.Context, playlistID string, trackIDs []string) error { - return s.favRepo.ReorderPlaylistTracks(ctx, playlistID, trackIDs) +// GetFavouriteArtistIDs returns list of followed artist IDs. +func (s *FavouritePlaylistService) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { + return s.favRepo.GetFavouriteArtistIDs(ctx, userID) } -// RecordHistory stores an entry in listening history. -func (s *FavouritePlaylistService) RecordHistory(ctx context.Context, userID int64, trackID string, playedAt float64) error { - return s.favRepo.RecordHistory(ctx, userID, trackID, playedAt) +// SaveAlbums saves one or more albums for user. +func (s *FavouritePlaylistService) SaveAlbums(ctx context.Context, userID int64, albumIDs []string) (int, error) { + count := 0 + for _, aid := range albumIDs { + aid = strings.TrimSpace(aid) + if aid == "" { + continue + } + already, err := s.favRepo.SaveAlbum(ctx, userID, aid) + if err == nil && !already { + count++ + } + } + return count, nil } -// GetUserHistory returns unique recently played tracks for a user. -func (s *FavouritePlaylistService) GetUserHistory(ctx context.Context, userID int64, limit int) (*models.BrowseResponse, error) { - if limit <= 0 || limit > 200 { - limit = 100 +// GetSavedAlbums returns saved albums for user. +func (s *FavouritePlaylistService) GetSavedAlbums(ctx context.Context, userID int64, page, limit int) (*models.UserAlbumsResponse, error) { + albumIDs, savedAts, total, err := s.favRepo.GetSavedAlbumIDs(ctx, userID, page, limit) + if err != nil { + return nil, err } - trackIDs, err := s.favRepo.GetUserHistoryTrackIDs(ctx, userID, limit) + items := make([]models.UserAlbumItem, 0, len(albumIDs)) + for i, aid := range albumIDs { + var albumDoc any + if s.artistAlbumRepo != nil { + if alb, err := s.artistAlbumRepo.GetAlbumByID(ctx, aid); err == nil && alb != nil { + albumDoc = alb + } + } + var savedAt float64 + if i < len(savedAts) { + savedAt = savedAts[i] + } + items = append(items, models.UserAlbumItem{ + AlbumID: aid, + Album: albumDoc, + SavedAt: savedAt, + }) + } + + return &models.UserAlbumsResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + }, nil +} + +// RecordListeningEvents records bulk telemetry events. +func (s *FavouritePlaylistService) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { + return s.favRepo.RecordListeningEvents(ctx, userID, events) +} + +// GetUserHistory returns recent track items. +func (s *FavouritePlaylistService) GetUserHistory(ctx context.Context, userID int64, limit int) ([]*models.BrowseItem, error) { + ids, err := s.favRepo.GetUserHistoryTrackIDs(ctx, userID, limit) if err != nil { return nil, err } - tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + tracks, err := s.trackRepo.GetByIDs(ctx, ids) if err != nil { return nil, err } - items := make([]*models.BrowseItem, 0, len(tracks)) + byID := make(map[string]*models.Track) for _, t := range tracks { - items = append(items, t.ToBrowseItem()) + byID[t.ID] = t } - return &models.BrowseResponse{ - Items: items, - Total: int64(len(items)), - Page: 1, - PerPage: limit, - TotalPages: 1, - }, nil + items := make([]*models.BrowseItem, 0, len(ids)) + for _, id := range ids { + if t, ok := byID[id]; ok { + items = append(items, t.ToBrowseItem()) + } + } + return items, nil } -// RecordListeningEvents records telemetry playback events. -func (s *FavouritePlaylistService) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { - return s.favRepo.RecordListeningEvents(ctx, userID, events) +// GetUserTopPlayed returns user's top played tracks. +func (s *FavouritePlaylistService) GetUserTopPlayed(ctx context.Context, userID int64, page, limit int) ([]*models.BrowseItem, int64, error) { + ids, total, err := s.favRepo.GetUserTopPlayedTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, 0, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, 0, err + } + + byID := make(map[string]*models.Track) + for _, t := range tracks { + byID[t.ID] = t + } + + items := make([]*models.BrowseItem, 0, len(ids)) + for _, id := range ids { + if t, ok := byID[id]; ok { + items = append(items, t.ToBrowseItem()) + } + } + return items, total, nil } diff --git a/internal/services/favourite_playlist_test.go b/internal/services/favourite_playlist_test.go new file mode 100644 index 0000000..4524600 --- /dev/null +++ b/internal/services/favourite_playlist_test.go @@ -0,0 +1,356 @@ +package services + +import ( + "context" + "testing" + + "streamgo/internal/models" +) + +type mockFavRepo struct { + playlists map[string]*models.UserPlaylist + tracks map[string][]string // playlistID -> trackIDs + favourites map[int64][]string // userID -> trackIDs + artists map[int64][]string // userID -> artistIDs + savedAlbums map[int64][]string // userID -> albumIDs +} + +func newMockFavRepo() *mockFavRepo { + return &mockFavRepo{ + playlists: make(map[string]*models.UserPlaylist), + tracks: make(map[string][]string), + favourites: make(map[int64][]string), + artists: make(map[int64][]string), + savedAlbums: make(map[int64][]string), + } +} + +func (m *mockFavRepo) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + for _, id := range m.favourites[userID] { + if id == trackID { + return true, nil + } + } + m.favourites[userID] = append(m.favourites[userID], trackID) + return false, nil +} + +func (m *mockFavRepo) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + ids := m.favourites[userID] + for i, id := range ids { + if id == trackID { + m.favourites[userID] = append(ids[:i], ids[i+1:]...) + return true, nil + } + } + return false, nil +} + +func (m *mockFavRepo) GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + ids := m.favourites[userID] + now := float64(1700000000) + return ids, int64(len(ids)), &now, nil +} + +func (m *mockFavRepo) GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + ids := m.favourites[userID] + now := float64(1700000000) + return ids, int64(len(ids)), &now, nil +} + +func (m *mockFavRepo) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + for _, id := range m.artists[userID] { + if id == artistID { + return true, nil + } + } + m.artists[userID] = append(m.artists[userID], artistID) + return false, nil +} + +func (m *mockFavRepo) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + ids := m.artists[userID] + for i, id := range ids { + if id == artistID { + m.artists[userID] = append(ids[:i], ids[i+1:]...) + return true, nil + } + } + return false, nil +} + +func (m *mockFavRepo) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { + return m.artists[userID], nil +} + +func (m *mockFavRepo) CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error { + m.playlists[p.ID] = p + return nil +} + +func (m *mockFavRepo) GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) { + var out []*models.UserPlaylist + for _, p := range m.playlists { + if p.UserID == userID { + out = append(out, p) + } + } + return out, nil +} + +func (m *mockFavRepo) GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) { + p, ok := m.playlists[playlistID] + if !ok || p.UserID != userID { + return nil, nil + } + return p, nil +} + +func (m *mockFavRepo) GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { + return m.playlists[playlistID], nil +} + +func (m *mockFavRepo) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) { + p := m.playlists[playlistID] + if p == nil || p.UserID != userID { + return nil, nil + } + p.Name = name + p.CoverID = coverID + p.CoverURL = coverURL + p.NormalThumbnail = normalThumbnail + return p, nil +} + +func (m *mockFavRepo) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { + delete(m.playlists, playlistID) + delete(m.tracks, playlistID) + return nil +} + +func (m *mockFavRepo) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) { + m.tracks[playlistID] = append(m.tracks[playlistID], trackIDs...) + return len(trackIDs), nil +} + +func (m *mockFavRepo) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { + ids := m.tracks[playlistID] + for i, id := range ids { + if id == trackID { + m.tracks[playlistID] = append(ids[:i], ids[i+1:]...) + return nil + } + } + return nil +} + +func (m *mockFavRepo) GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) { + ids := m.tracks[playlistID] + return ids, int64(len(ids)), nil +} + +func (m *mockFavRepo) GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) { + ids := m.tracks[playlistID] + if len(ids) > limit { + return ids[:limit], nil + } + return ids, nil +} + +func (m *mockFavRepo) SaveAlbum(ctx context.Context, userID int64, albumID string) (bool, error) { + for _, id := range m.savedAlbums[userID] { + if id == albumID { + return true, nil + } + } + m.savedAlbums[userID] = append(m.savedAlbums[userID], albumID) + return false, nil +} + +func (m *mockFavRepo) GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) ([]string, []float64, int64, error) { + ids := m.savedAlbums[userID] + ts := make([]float64, len(ids)) + return ids, ts, int64(len(ids)), nil +} + +func (m *mockFavRepo) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { + return len(events), nil +} + +func (m *mockFavRepo) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) { + return []string{}, nil +} + +func (m *mockFavRepo) GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) { + return []string{}, 0, nil +} + +type mockTrackRepo struct { + tracks map[string]*models.Track +} + +func (m *mockTrackRepo) GetByID(ctx context.Context, id string) (*models.Track, error) { + return m.tracks[id], nil +} + +func (m *mockTrackRepo) GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) { + var out []*models.Track + for _, id := range ids { + if t, ok := m.tracks[id]; ok { + out = append(out, t) + } + } + return out, nil +} + +func (m *mockTrackRepo) List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) { + return nil, 0, nil +} + +func (m *mockTrackRepo) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) { + return nil, nil +} + +func (m *mockTrackRepo) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) { + return nil, nil +} + +func (m *mockTrackRepo) GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) { + return nil, nil +} + +func (m *mockTrackRepo) GetChannelIDs(ctx context.Context) ([]int64, error) { + return nil, nil +} + +func (m *mockTrackRepo) IncrementPlayCount(ctx context.Context, id string) error { + return nil +} + +func (m *mockTrackRepo) UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error { + return nil +} + +func TestFavouritePlaylistService(t *testing.T) { + ctx := context.Background() + favRepo := newMockFavRepo() + trackRepo := &mockTrackRepo{ + tracks: map[string]*models.Track{ + "trk1": { + ID: "trk1", + Audio: models.AudioMeta{ + Title: "Track One", + Artist: "Artist A", + }, + Spotify: models.SpotifyMeta{ + CoverURL: "https://example.com/cover1.jpg", + }, + }, + "trk2": { + ID: "trk2", + Audio: models.AudioMeta{ + Title: "Track Two", + Artist: "Artist B", + }, + Spotify: models.SpotifyMeta{ + CoverURL: "https://example.com/cover2.jpg", + }, + }, + }, + } + + svc := NewFavouritePlaylistService(favRepo, trackRepo, nil) + userID := int64(12345) + + // 1. Create playlist + p, err := svc.CreatePlaylist(ctx, userID, "My Chill Mix") + if err != nil { + t.Fatalf("failed to create playlist: %v", err) + } + if p.Name != "My Chill Mix" { + t.Errorf("expected name 'My Chill Mix', got '%s'", p.Name) + } + + // 2. Add tracks to playlist + added, err := svc.AddTracksToPlaylist(ctx, p.PlaylistID, userID, []string{"trk1", "trk2"}) + if err != nil { + t.Fatalf("failed to add tracks: %v", err) + } + if added != 2 { + t.Errorf("expected 2 tracks added, got %d", added) + } + + // 3. List playlists (check collage thumbnails) + lists, err := svc.GetUserPlaylists(ctx, userID) + if err != nil { + t.Fatalf("failed to list playlists: %v", err) + } + if len(lists.Items) != 1 { + t.Fatalf("expected 1 playlist, got %d", len(lists.Items)) + } + if len(lists.Items[0].Thumbnails) < 2 { + t.Errorf("expected at least 2 collage thumbnails, got %d", len(lists.Items[0].Thumbnails)) + } + + // 4. Rename playlist + renamed, err := svc.RenamePlaylist(ctx, p.PlaylistID, userID, "Renamed Mix") + if err != nil { + t.Fatalf("failed to rename playlist: %v", err) + } + if renamed.Name != "Renamed Mix" { + t.Errorf("expected renamed 'Renamed Mix', got '%s'", renamed.Name) + } + + // 5. Delete playlist + err = svc.DeletePlaylist(ctx, p.PlaylistID, userID) + if err != nil { + t.Fatalf("failed to delete playlist: %v", err) + } + + // 6. Test Favourites + already, err := svc.AddFavourite(ctx, userID, "trk1") + if err != nil || already { + t.Fatalf("failed to add favourite: %v (already: %v)", err, already) + } + + favIDs, err := svc.GetFavouriteIDs(ctx, userID, 1, 100) + if err != nil { + t.Fatalf("failed to get favourite IDs: %v", err) + } + if len(favIDs.IDs) != 1 || favIDs.IDs[0] != "trk1" { + t.Errorf("expected ['trk1'], got %v", favIDs.IDs) + } + + deleted, err := svc.RemoveFavourite(ctx, userID, "trk1") + if err != nil || !deleted { + t.Fatalf("failed to remove favourite: %v", err) + } + + // 7. Test Artist Favourites + alreadyA, err := svc.AddFavouriteArtist(ctx, userID, "artist_xyz") + if err != nil || alreadyA { + t.Fatalf("failed to add favourite artist: %v", err) + } + + artistIDs, err := svc.GetFavouriteArtistIDs(ctx, userID) + if err != nil { + t.Fatalf("failed to get favourite artist IDs: %v", err) + } + if len(artistIDs) != 1 || artistIDs[0] != "artist_xyz" { + t.Errorf("expected ['artist_xyz'], got %v", artistIDs) + } + + deletedA, err := svc.RemoveFavouriteArtist(ctx, userID, "artist_xyz") + if err != nil || !deletedA { + t.Fatalf("failed to remove favourite artist: %v", err) + } + + // 8. Test Saved Albums + count, err := svc.SaveAlbums(ctx, userID, []string{"alb_1", "alb_2"}) + if err != nil { + t.Fatalf("failed to save albums: %v", err) + } + if count != 2 { + t.Errorf("expected 2 albums saved, got %d", count) + } +} diff --git a/internal/services/filter_mode_test.go b/internal/services/filter_mode_test.go new file mode 100644 index 0000000..6aa986e --- /dev/null +++ b/internal/services/filter_mode_test.go @@ -0,0 +1,118 @@ +package services + +import ( + "context" + "testing" + + "streamgo/internal/config" +) + +func TestParseFilterMode(t *testing.T) { + tests := []struct { + input any + expected int + name string + }{ + {0, FilterModeGroupOnly, "int 0"}, + {1, FilterModeAnyone, "int 1"}, + {2, FilterModeHybrid, "int 2"}, + {"0", FilterModeGroupOnly, "str 0"}, + {"group_only", FilterModeGroupOnly, "str group_only"}, + {"1", FilterModeAnyone, "str 1"}, + {"anyone", FilterModeAnyone, "str anyone"}, + {"any", FilterModeAnyone, "str any"}, + {"all", FilterModeAnyone, "str all"}, + {"2", FilterModeHybrid, "str 2"}, + {"hybrid", FilterModeHybrid, "str hybrid"}, + {"whitelist", FilterModeHybrid, "str whitelist"}, + {"unknown", FilterModeGroupOnly, "unknown fallback"}, + {nil, FilterModeGroupOnly, "nil fallback"}, + } + + for _, tt := range tests { + got := ParseFilterMode(tt.input) + if got != tt.expected { + t.Errorf("[%s] ParseFilterMode(%v) = %d; want %d", tt.name, tt.input, got, tt.expected) + } + } +} + +func TestFilterModeToString(t *testing.T) { + if FilterModeToString(FilterModeGroupOnly) != "group_only" { + t.Errorf("expected group_only") + } + if FilterModeToString(FilterModeAnyone) != "anyone" { + t.Errorf("expected anyone") + } + if FilterModeToString(FilterModeHybrid) != "hybrid" { + t.Errorf("expected hybrid") + } +} + +func TestAccessFilterLogic(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{ + ChannelID: -1003961478268, + DumpChannelID: -1004300252384, + FilterMode: FilterModeGroupOnly, + } + + filter := &AccessFilter{ + cfg: cfg, + mode: FilterModeGroupOnly, + allowedChats: make(map[int64]bool), + bannedChats: make(map[int64]bool), + } + filter.allowedChats[cfg.ChannelID] = true + filter.allowedChats[cfg.DumpChannelID] = true + + randomChat := int64(-1009999999999) + allowedContributor := int64(-1008888888888) + + // Mode 0 (GROUP_ONLY) + filter.mode = FilterModeGroupOnly + if !filter.IsChatAllowed(ctx, cfg.ChannelID) { + t.Errorf("Mode 0: ChannelID should be allowed") + } + if !filter.IsChatAllowed(ctx, cfg.DumpChannelID) { + t.Errorf("Mode 0: DumpChannelID should be allowed") + } + if filter.IsChatAllowed(ctx, randomChat) { + t.Errorf("Mode 0: Random chat should be rejected") + } + + // Mode 1 (ANYONE) + filter.mode = FilterModeAnyone + if !filter.IsChatAllowed(ctx, randomChat) { + t.Errorf("Mode 1: Random chat should be allowed") + } + + // Strict Ban across all modes + filter.bannedChats[randomChat] = true + if filter.IsChatAllowed(ctx, randomChat) { + t.Errorf("Mode 1: Banned chat must be rejected strictly") + } + + // Mode 2 (HYBRID) + filter.mode = FilterModeHybrid + delete(filter.bannedChats, randomChat) + + if !filter.IsChatAllowed(ctx, cfg.ChannelID) { + t.Errorf("Mode 2: ChannelID should be allowed") + } + if filter.IsChatAllowed(ctx, randomChat) { + t.Errorf("Mode 2: Unlisted random chat should be rejected") + } + + // Add contributor to allowed + filter.allowedChats[allowedContributor] = true + if !filter.IsChatAllowed(ctx, allowedContributor) { + t.Errorf("Mode 2: Listed allowed contributor should be allowed") + } + + // Ban contributor -> strict ban must reject + filter.bannedChats[allowedContributor] = true + if filter.IsChatAllowed(ctx, allowedContributor) { + t.Errorf("Mode 2: Banned contributor must be rejected strictly") + } +} diff --git a/internal/services/history_service.go b/internal/services/history_service.go new file mode 100644 index 0000000..e119539 --- /dev/null +++ b/internal/services/history_service.go @@ -0,0 +1,120 @@ +package services + +import ( + "context" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// HistoryService handles playback history and telemetry processing. +type HistoryService struct { + histRepo repository.HistoryRepository + trackRepo repository.TrackRepository +} + +// NewHistoryService creates a new HistoryService. +func NewHistoryService(histRepo repository.HistoryRepository, trackRepo repository.TrackRepository) *HistoryService { + return &HistoryService{ + histRepo: histRepo, + trackRepo: trackRepo, + } +} + +// GetHistory returns user playback history as browse items. +func (s *HistoryService) GetHistory(ctx context.Context, userID int64, limit int) (*models.BrowseResponse, error) { + if limit <= 0 { + limit = 50 + } + + trackIDs, err := s.histRepo.GetUserHistory(ctx, userID, limit) + if err != nil { + return nil, err + } + if len(trackIDs) == 0 { + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: 0, + Items: []*models.BrowseItem{}, + }, nil + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + var items []*models.BrowseItem + for _, tid := range trackIDs { + if t, ok := trackMap[tid]; ok { + items = append(items, t.ToBrowseItem()) + } + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil +} + +// GetTopPlayed returns user's most played tracks as browse items. +func (s *HistoryService) GetTopPlayed(ctx context.Context, userID int64, limit int) (*models.BrowseResponse, error) { + if limit <= 0 { + limit = 50 + } + + trackIDs, err := s.histRepo.GetUserTopPlayed(ctx, userID, limit) + if err != nil { + return nil, err + } + if len(trackIDs) == 0 { + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: 0, + Items: []*models.BrowseItem{}, + }, nil + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + var items []*models.BrowseItem + for _, tid := range trackIDs { + if t, ok := trackMap[tid]; ok { + items = append(items, t.ToBrowseItem()) + } + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil +} + +// RecordPlay logs playback directly (e.g. from stream). +func (s *HistoryService) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { + return s.histRepo.RecordPlay(ctx, userID, trackID, source) +} + +// RecordEvents records batch client listening events. +func (s *HistoryService) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + return s.histRepo.RecordListeningEvents(ctx, userID, events) +} diff --git a/internal/services/internal_services_test.go b/internal/services/internal_services_test.go index 6f3cc61..b47a980 100644 --- a/internal/services/internal_services_test.go +++ b/internal/services/internal_services_test.go @@ -55,6 +55,13 @@ func TestMetadataFingerprint(t *testing.T) { if fpClose1 != fpClose2 { t.Errorf("expected %q == %q within 2-second bucket", fpClose1, fpClose2) } + + // Multilingual Unicode fingerprint test (RADWIMPS - 夢灯籠 from 君の名は。) + fpJP := MetadataFingerprint("夢灯籠", "RADWIMPS", "君の名は。", 131) + expectedJP := "夢灯籠|radwimps|君の名は|132" + if fpJP != expectedJP { + t.Errorf("expected %q, got %q", expectedJP, fpJP) + } } func TestAccessFilter(t *testing.T) { diff --git a/internal/services/lyrics_enrichment_service.go b/internal/services/lyrics_enrichment_service.go index a84e8b7..3afb07b 100644 --- a/internal/services/lyrics_enrichment_service.go +++ b/internal/services/lyrics_enrichment_service.go @@ -4,38 +4,63 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" "time" + "streamgo/internal/config" "streamgo/internal/logger" ) var logLyrics = logger.New("lyrics") +const ( + musixmatchBaseURL = "https://apic.musixmatch.com/ws/1.1" + musixmatchAppID = "mac-ios-v2.0" +) + // LyricsResult bundles resolved lyrics and alternative multilingual titles. type LyricsResult struct { Lyrics string SyncedLyrics string + Kind string // "richsync" or "lrc" + Source string // "musixmatch" or "lrclib" Titles map[string]any } -// LyricsEnrichmentService fetches lyrics and alternative titles from LRCLIB and Musixmatch. +// LyricsEnrichmentService fetches lyrics and alternative titles from Musixmatch and LRCLIB. type LyricsEnrichmentService struct { + cfg *config.Config httpClient *http.Client + + tokenMu sync.RWMutex + cachedToken string + tokenSavedAt float64 } // NewLyricsEnrichmentService creates a new LyricsEnrichmentService. -func NewLyricsEnrichmentService() *LyricsEnrichmentService { +func NewLyricsEnrichmentService(cfg *config.Config) *LyricsEnrichmentService { return &LyricsEnrichmentService{ + cfg: cfg, httpClient: &http.Client{ - Timeout: 8 * time.Second, + Timeout: 10 * time.Second, }, } } // FetchLyrics searches for lyrics and romanized/localized titles. func (s *LyricsEnrichmentService) FetchLyrics(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + if s.cfg != nil && !s.cfg.Lyrics { + return nil, nil // Lyrics fetching disabled + } + cleanTitle := StripNoise(title) cleanArtist := StripNoise(artist) cleanAlbum := StripNoise(album) @@ -44,15 +69,299 @@ func (s *LyricsEnrichmentService) FetchLyrics(ctx context.Context, title, artist return nil, fmt.Errorf("title cannot be empty") } - // 1. Query LRCLIB API - res, err := s.queryLRCLIB(ctx, cleanTitle, cleanArtist, cleanAlbum) - if err == nil && res != nil { - return res, nil + // 1. Try Musixmatch if enabled (defaults to true if config flag set) + if s.cfg == nil || s.cfg.Musixmatch { + res, err := s.queryMusixmatch(ctx, cleanTitle, cleanArtist, cleanAlbum) + if err == nil && res != nil && (res.Lyrics != "" || len(res.Titles) > 0) { + return res, nil + } + if err != nil { + logLyrics.Debugf("Musixmatch query failed for %s - %s: %v", cleanArtist, cleanTitle, err) + } + } + + // 2. Try LRCLIB if enabled or as fallback + if s.cfg == nil || s.cfg.LRCLIB { + res, err := s.queryLRCLIB(ctx, cleanTitle, cleanArtist, cleanAlbum) + if err == nil && res != nil { + return res, nil + } + if err != nil { + logLyrics.Debugf("LRCLIB query failed for %s - %s: %v", cleanArtist, cleanTitle, err) + } } return nil, fmt.Errorf("no lyrics found for %s - %s", artist, title) } +func (s *LyricsEnrichmentService) getUserToken(ctx context.Context, forceRefresh bool) string { + s.tokenMu.RLock() + now := float64(time.Now().Unix()) + if !forceRefresh && s.cachedToken != "" && (now-s.tokenSavedAt) < 24*3600 { + tok := s.cachedToken + s.tokenMu.RUnlock() + return tok + } + s.tokenMu.RUnlock() + + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + + if !forceRefresh && s.cachedToken != "" && (now-s.tokenSavedAt) < 24*3600 { + return s.cachedToken + } + + // Try reading from token files + tokenCandidates := []string{ + "/home/misfit/Work/StreamXBot/cookies/.musixmatch_token", + "./cookies/.musixmatch_token", + filepath.Join(os.TempDir(), ".musixmatch_token"), + } + + if !forceRefresh { + for _, path := range tokenCandidates { + if data, err := os.ReadFile(path); err == nil { + var tokenDoc struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &tokenDoc); err == nil && tokenDoc.Token != "" { + s.cachedToken = strings.TrimSpace(tokenDoc.Token) + s.tokenSavedAt = now + return s.cachedToken + } + } + } + } + + // Request new user token from Musixmatch + tokenURL := fmt.Sprintf("%s/token.get?app_id=%s", musixmatchBaseURL, musixmatchAppID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err == nil { + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + resp, err := s.httpClient.Do(req) + if err == nil { + defer resp.Body.Close() + var doc struct { + Message struct { + Header struct { + StatusCode int `json:"status_code"` + } `json:"header"` + Body struct { + UserToken string `json:"user_token"` + } `json:"body"` + } `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err == nil { + if doc.Message.Body.UserToken != "" { + s.cachedToken = strings.TrimSpace(doc.Message.Body.UserToken) + s.tokenSavedAt = now + return s.cachedToken + } + } + } + } + + // Fallback hardcoded / known seed token if dynamic token acquisition fails + fallback := "26091748e0125fcea9641300d8bbf8995de1514987802fc2c70d6f" + s.cachedToken = fallback + s.tokenSavedAt = now + return fallback +} + +func (s *LyricsEnrichmentService) queryMusixmatch(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + token := s.getUserToken(ctx, false) + if token == "" { + return nil, fmt.Errorf("failed to acquire musixmatch token") + } + + endpoint := fmt.Sprintf("%s/macro.subtitles.get?app_id=%s&usertoken=%s&subtitle_format=mxm&q_track=%s&q_artist=%s", + musixmatchBaseURL, + musixmatchAppID, + url.QueryEscape(token), + url.QueryEscape(title), + url.QueryEscape(artist), + ) + if album != "" { + endpoint += fmt.Sprintf("&q_album=%s", url.QueryEscape(album)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var payload struct { + Message struct { + Header struct { + StatusCode int `json:"status_code"` + } `json:"header"` + Body struct { + MacroCalls map[string]struct { + Message struct { + Header struct { + StatusCode int `json:"status_code"` + } `json:"header"` + Body any `json:"body"` + } `json:"message"` + } `json:"macro_calls"` + } `json:"body"` + } `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + macro := payload.Message.Body.MacroCalls + trackCall, hasTrack := macro["matcher.track.get"] + if !hasTrack { + return nil, fmt.Errorf("no matcher.track.get in musixmatch response") + } + + trackBodyBytes, _ := json.Marshal(trackCall.Message.Body) + var trackData struct { + Track struct { + TrackID int64 `json:"track_id"` + TrackName string `json:"track_name"` + HasRichsync int `json:"has_richsync"` + HasSubtitles int `json:"has_subtitles"` + TrackNameTranslationList []struct { + TrackNameTranslation struct { + Language string `json:"language"` + Translation string `json:"translation"` + } `json:"track_name_translation"` + } `json:"track_name_translation_list"` + } `json:"track"` + } + _ = json.Unmarshal(trackBodyBytes, &trackData) + + originalTitle := title + if trackData.Track.TrackName != "" { + originalTitle = trackData.Track.TrackName + } + + // Extract romanized title + romanized := "" + for _, item := range trackData.Track.TrackNameTranslationList { + lang := strings.ToLower(strings.TrimSpace(item.TrackNameTranslation.Language)) + val := strings.TrimSpace(item.TrackNameTranslation.Translation) + if lang == "rj" || lang == "rk" || lang == "romanized" || lang == "romaja" { + romanized = val + break + } + if (lang == "u0" || lang == "zr") && !isCJK(val) && romanized == "" { + romanized = val + } + } + + // Fallback to Japanese romanizer if title is CJK + if romanized == "" && isCJK(originalTitle) { + romanized = romanizeJapanese(originalTitle) + } + + titles := map[string]any{ + "original": originalTitle, + } + if romanized != "" && !strings.EqualFold(romanized, originalTitle) { + titles["romanized"] = romanized + } + + res := &LyricsResult{ + Titles: titles, + } + + // 1. If has_richsync is true and track_id > 0, fetch word-level richsync + if trackData.Track.TrackID > 0 && trackData.Track.HasRichsync == 1 { + richsyncURL := fmt.Sprintf("%s/track.richsync.get?app_id=%s&usertoken=%s&track_id=%d", + musixmatchBaseURL, + musixmatchAppID, + url.QueryEscape(token), + trackData.Track.TrackID, + ) + rReq, err := http.NewRequestWithContext(ctx, http.MethodGet, richsyncURL, nil) + if err == nil { + rReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + if rResp, err := s.httpClient.Do(rReq); err == nil { + defer rResp.Body.Close() + var rDoc struct { + Message struct { + Body struct { + Richsync struct { + RichsyncBody string `json:"richsync_body"` + } `json:"richsync"` + } `json:"body"` + } `json:"message"` + } + if err := json.NewDecoder(rResp.Body).Decode(&rDoc); err == nil { + richText := strings.TrimSpace(rDoc.Message.Body.Richsync.RichsyncBody) + if richText != "" { + if lrc := parseRichsyncJSONToLRC(richText); lrc != "" { + res.Lyrics = lrc + res.SyncedLyrics = lrc + res.Kind = "richsync" + res.Source = "musixmatch" + return res, nil + } + } + } + } + } + } + + // 2. Check track.subtitles.get from macro calls + if subCall, ok := macro["track.subtitles.get"]; ok { + subBodyBytes, _ := json.Marshal(subCall.Message.Body) + var subData struct { + SubtitleList []struct { + Subtitle struct { + SubtitleBody string `json:"subtitle_body"` + } `json:"subtitle"` + } `json:"subtitle_list"` + } + if err := json.Unmarshal(subBodyBytes, &subData); err == nil && len(subData.SubtitleList) > 0 { + subBody := strings.TrimSpace(subData.SubtitleList[0].Subtitle.SubtitleBody) + if subBody != "" { + lrc := parseSubtitlesJSONToLRC(subBody) + if lrc != "" { + res.Lyrics = lrc + res.SyncedLyrics = lrc + res.Kind = "lrc" + res.Source = "musixmatch" + return res, nil + } + } + } + } + + // 3. Plain lyrics from macro calls + if lyrCall, ok := macro["track.lyrics.get"]; ok { + lyrBodyBytes, _ := json.Marshal(lyrCall.Message.Body) + var lyrData struct { + Lyrics struct { + LyricsBody string `json:"lyrics_body"` + } `json:"lyrics"` + } + if err := json.Unmarshal(lyrBodyBytes, &lyrData); err == nil { + plain := strings.TrimSpace(lyrData.Lyrics.LyricsBody) + if plain != "" { + res.Lyrics = plain + res.Kind = "lrc" + res.Source = "musixmatch" + return res, nil + } + } + } + + return res, nil +} + func (s *LyricsEnrichmentService) queryLRCLIB(ctx context.Context, title, artist, album string) (*LyricsResult, error) { apiURL := fmt.Sprintf("https://lrclib.net/api/get?track_name=%s&artist_name=%s", url.QueryEscape(title), @@ -98,8 +407,180 @@ func (s *LyricsEnrichmentService) queryLRCLIB(ctx context.Context, title, artist return nil, fmt.Errorf("empty lyrics from lrclib") } + titles := map[string]any{ + "original": title, + } + return &LyricsResult{ Lyrics: bestLyrics, SyncedLyrics: payload.SyncedLyrics, + Kind: "lrc", + Source: "lrclib", + Titles: titles, }, nil } + +func formatLRCTimestamp(totalSeconds float64) string { + if totalSeconds < 0 { + totalSeconds = 0 + } + m := int(totalSeconds / 60) + secF := totalSeconds - float64(m*60) + s := int(secF) + hh := int(math.Round((secF - float64(s)) * 100)) + if hh >= 100 { + hh = 0 + s++ + } + if s >= 60 { + s = 0 + m++ + } + return fmt.Sprintf("[%02d:%02d.%02d]", m, s, hh) +} + +func formatWordTimestamp(totalSeconds float64) string { + if totalSeconds < 0 { + totalSeconds = 0 + } + m := int(totalSeconds / 60) + secF := totalSeconds - float64(m*60) + s := int(secF) + hh := int(math.Round((secF - float64(s)) * 100)) + if hh >= 100 { + hh = 0 + s++ + } + if s >= 60 { + s = 0 + m++ + } + return fmt.Sprintf("<%02d:%02d.%02d>", m, s, hh) +} + +func parseRichsyncJSONToLRC(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "" + } + + var items []struct { + TS float64 `json:"ts"` + TE float64 `json:"te"` + X string `json:"x"` + L []struct { + C string `json:"c"` + O float64 `json:"o"` + } `json:"l"` + } + + if err := json.Unmarshal([]byte(body), &items); err != nil || len(items) == 0 { + return "" + } + + var out []string + for _, it := range items { + lineText := strings.TrimSpace(it.X) + if lineText == "" && len(it.L) == 0 { + continue + } + + lineTag := formatLRCTimestamp(it.TS) + if len(it.L) > 0 { + var parts []string + parts = append(parts, lineTag) + for _, w := range it.L { + c := w.C + if strings.TrimSpace(c) == "" { + parts = append(parts, c) + } else { + parts = append(parts, fmt.Sprintf("%s%s", formatWordTimestamp(it.TS+w.O), c)) + } + } + out = append(out, strings.TrimRight(strings.Join(parts, ""), " ")) + } else { + out = append(out, strings.TrimRight(fmt.Sprintf("%s %s", lineTag, lineText), " ")) + } + } + + return strings.Join(out, "\n") +} + +func parseSubtitlesJSONToLRC(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "" + } + + // Already LRC format text with [mm:ss timestamps + if len(body) > 6 && body[0] == '[' && (body[1] >= '0' && body[1] <= '9') && body[3] == ':' { + return body + } + + var items []struct { + Text string `json:"text"` + Time struct { + Total float64 `json:"total"` + } `json:"time"` + } + + if err := json.Unmarshal([]byte(body), &items); err != nil || len(items) == 0 { + return body + } + + var out []string + for _, it := range items { + ts := formatLRCTimestamp(it.Time.Total) + t := strings.TrimRight(it.Text, " ") + if t != "" { + out = append(out, fmt.Sprintf("%s %s", ts, t)) + } else { + out = append(out, ts) + } + } + + return strings.Join(out, "\n") +} + +func isCJK(text string) bool { + for _, r := range text { + if (r >= 0x3040 && r <= 0x309f) || // Hiragana + (r >= 0x30a0 && r <= 0x30ff) || // Katakana + (r >= 0x4e00 && r <= 0x9fff) || // CJK Ideographs + (r >= 0xac00 && r <= 0xd7af) { // Hangul + return true + } + } + return false +} + +func romanizeJapanese(text string) string { + if text == "" || !isCJK(text) { + return "" + } + + pyBins := []string{ + "/home/misfit/Work/StreamXBot/.venv/bin/python3", + "python3", + } + + script := `import pykakasi, sys; k = pykakasi.kakasi(); print(' '.join(w.get('hepburn','').capitalize() for w in k.convert(sys.argv[1])))` + + for _, py := range pyBins { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + cmd := exec.CommandContext(ctx, py, "-c", script, text) + out, err := cmd.Output() + cancel() + if err == nil { + res := strings.TrimSpace(string(out)) + if res != "" { + // Clean formatting + re := regexp.MustCompile(`\s+`) + res = re.ReplaceAllString(res, " ") + return res + } + } + } + + return "" +} diff --git a/internal/services/lyrics_enrichment_test.go b/internal/services/lyrics_enrichment_test.go new file mode 100644 index 0000000..9ac638f --- /dev/null +++ b/internal/services/lyrics_enrichment_test.go @@ -0,0 +1,66 @@ +package services + +import ( + "strings" + "testing" +) + +func TestParseRichsyncJSONToLRC(t *testing.T) { + sampleRichsync := `[ + { + "ts": 24.38, + "te": 29.65, + "l": [ + {"c": "あぁ", "o": 0}, + {"c": " ", "o": 4.78}, + {"c": "このまま僕たちの声が", "o": 4.84} + ], + "x": "あぁ このまま僕たちの声が" + } + ]` + + lrc := parseRichsyncJSONToLRC(sampleRichsync) + if !strings.Contains(lrc, "[00:24.38]") { + t.Errorf("Expected [00:24.38] in LRC, got: %s", lrc) + } + if !strings.Contains(lrc, "<00:24.38>あぁ") { + t.Errorf("Expected word timestamp <00:24.38>あぁ in LRC, got: %s", lrc) + } + if !strings.Contains(lrc, "このまま僕たちの声が") { + t.Errorf("Expected lyrics text in LRC, got: %s", lrc) + } +} + +func TestParseSubtitlesJSONToLRC(t *testing.T) { + sampleSubtitles := `[ + { + "text": "Hello world", + "time": {"total": 65.5} + } + ]` + + lrc := parseSubtitlesJSONToLRC(sampleSubtitles) + if !strings.Contains(lrc, "[01:05.50] Hello world") { + t.Errorf("Expected [01:05.50] Hello world, got: %s", lrc) + } +} + +func TestIsCJK(t *testing.T) { + if !isCJK("夢灯籠") { + t.Errorf("Expected 夢灯籠 to be detected as CJK") + } + if !isCJK("RADWIMPS - 君の名は。") { + t.Errorf("Expected Japanese text to be detected as CJK") + } + if isCJK("Hello World 123") { + t.Errorf("Expected English text not to be detected as CJK") + } +} + +func TestRomanizeJapanese(t *testing.T) { + res := romanizeJapanese("夢灯籠") + // If python pykakasi is installed on this system, verify result + if res != "" && res != "Yume Tourou" && res != "Yumetourou" { + t.Logf("romanizeJapanese('夢灯籠') produced: %s", res) + } +} diff --git a/internal/services/mediainfo.go b/internal/services/mediainfo.go index 6a95f8e..810047a 100644 --- a/internal/services/mediainfo.go +++ b/internal/services/mediainfo.go @@ -485,7 +485,7 @@ func BuildMetadataFingerprint(title, artist, album string, durationSec int32) st func normalizeText(val string) string { s := strings.ToLower(val) - re := regexp.MustCompile(`[^a-z0-9]+`) + re := regexp.MustCompile(`[^\p{L}\p{N}]+`) s = re.ReplaceAllString(s, " ") return strings.Join(strings.Fields(s), " ") } diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index a70aa31..07802a9 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -4,13 +4,14 @@ import ( "context" "errors" "fmt" - "io" "net/http" + "net/url" "os" "path/filepath" "regexp" "strconv" "strings" + "sync/atomic" "time" "github.com/gotd/td/tg" @@ -23,7 +24,10 @@ import ( var logStream = logger.New("stream") -var rangeRegex = regexp.MustCompile(`^bytes=(\d*)-(\d*)$`) +var rangeRegex = regexp.MustCompile(`(?i)^bytes\s*=\s*(\d*)\s*-\s*(\d*)$`) + +// ErrRangeNotSatisfiable is returned when the requested byte range is outside the file bounds. +var ErrRangeNotSatisfiable = errors.New("range not satisfiable") // StreamService coordinates audio range parsing and streaming. type StreamService struct { @@ -64,15 +68,15 @@ func ParseRange(rangeHeader string, totalSize int64) (*ByteRange, error) { return nil, fmt.Errorf("invalid range format: %s", rangeHeader) } - startStr := matches[1] - endStr := matches[2] - - var start, end int64 + startStr := strings.TrimSpace(matches[1]) + endStr := strings.TrimSpace(matches[2]) if startStr == "" && endStr == "" { return nil, fmt.Errorf("empty range boundaries") } + var start, end int64 + if startStr == "" { n, err := strconv.ParseInt(endStr, 10, 64) if err != nil || n <= 0 { @@ -103,8 +107,8 @@ func ParseRange(rangeHeader string, totalSize int64) (*ByteRange, error) { } } - if start >= totalSize { - return nil, fmt.Errorf("range start %d out of bounds (total: %d)", start, totalSize) + if totalSize > 0 && start >= totalSize { + return nil, ErrRangeNotSatisfiable } return &ByteRange{ @@ -158,7 +162,11 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac f, err := os.Open(cacheFile) if err == nil { defer f.Close() - w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) + if w.Header().Get("Content-Disposition") == "" { + fallback := strings.ReplaceAll(filename, `"`, `_`) + encoded := url.QueryEscape(filename) + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + } http.ServeContent(w, r, filename, info.ModTime(), f) return } @@ -170,17 +178,22 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac if rangeHeader != "" { var err error byteRange, err = ParseRange(rangeHeader, totalSize) - if err != nil { + if errors.Is(err, ErrRangeNotSatisfiable) { w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) http.Error(w, "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable) return } + // If err != nil (malformed syntax), RFC 7233 says ignore Range and serve full content (byteRange == nil) } // 5. Setup Response Headers w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Content-Type", mimeType) - w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) + if w.Header().Get("Content-Disposition") == "" { + fallback := strings.ReplaceAll(filename, `"`, `_`) + encoded := url.QueryEscape(filename) + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + } status := http.StatusOK var contentLength int64 = totalSize @@ -217,50 +230,6 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac s.streamFromTelegram(ctx, w, track, byteRange, totalSize) } -type rangeWriter struct { - w io.Writer - skip int64 - remain int64 - flusher http.Flusher -} - -func (rw *rangeWriter) Write(p []byte) (int, error) { - if rw.remain <= 0 { - return 0, io.EOF - } - - n := len(p) - if rw.skip > 0 { - if int64(n) <= rw.skip { - rw.skip -= int64(n) - return n, nil - } - p = p[rw.skip:] - rw.skip = 0 - } - - toWrite := p - if int64(len(toWrite)) > rw.remain { - toWrite = toWrite[:rw.remain] - } - - written, err := rw.w.Write(toWrite) - rw.remain -= int64(written) - - if rw.flusher != nil { - rw.flusher.Flush() - } - - if err != nil { - return written, err - } - if rw.remain <= 0 { - return n, io.EOF - } - - return n, nil -} - func (s *StreamService) streamFromTelegram( ctx context.Context, w http.ResponseWriter, @@ -278,7 +247,77 @@ func (s *StreamService) streamFromTelegram( logStream.Infof("streaming track %s: offset=%d, length=%d", track.ID, startOffset, bytesRemaining) - fileID := track.Telegram.FileID + // 1. Prefer acquiring a worker that already has a mapped file_id + var worker *telegram.ClientWorker + workers := s.tgService.Workers() + if track.Telegram.FileIDs != nil { + for _, w := range workers { + if w.Ready { + wID := strconv.FormatInt(w.ID, 10) + if wID == "0" || wID == "" { + if parts := strings.Split(w.Token, ":"); len(parts) > 0 { + wID = parts[0] + } + } + if fid, ok := track.Telegram.FileIDs[wID]; ok && fid != "" { + worker = s.tgService.AcquireWorkerForBot(wID) + break + } + } + } + } + if worker == nil { + worker = s.tgService.AcquireWorker() + } + defer s.tgService.ReleaseWorker(worker) + + workerIDStr := strconv.FormatInt(worker.ID, 10) + if workerIDStr == "0" || workerIDStr == "" { + if parts := strings.Split(worker.Token, ":"); len(parts) > 0 { + workerIDStr = parts[0] + } + } + + fileID := "" + if track.Telegram.FileIDs != nil && workerIDStr != "0" { + fileID = track.Telegram.FileIDs[workerIDStr] + } + + fetchFresh := func() string { + if worker.Ready && track.CacheChatID != 0 && track.CacheMessageID != 0 { + if fid, err := worker.FetchFileIDForMessage(ctx, track.CacheChatID, track.CacheMessageID); err == nil && fid != "" { + go func(tid, wid, f string) { + _ = s.repo.UpdateWorkerFileID(context.Background(), tid, wid, f) + }(track.ID, workerIDStr, fid) + return fid + } + } + if worker.Ready && track.SourceChatID != 0 && track.SourceMessageID != 0 { + if fid, err := worker.FetchFileIDForMessage(ctx, track.SourceChatID, track.SourceMessageID); err == nil && fid != "" { + go func(tid, wid, f string) { + _ = s.repo.UpdateWorkerFileID(context.Background(), tid, wid, f) + }(track.ID, workerIDStr, fid) + return fid + } + } + return "" + } + + // Fallback: If this worker does not have a cached file_id, fetch on the fly + if fileID == "" { + fileID = fetchFresh() + if fileID == "" && track.Telegram.FileID != "" { + primary := s.tgService.PrimaryWorker() + if primary != nil && primary.Ready && primary != worker { + s.tgService.ReleaseWorker(worker) + worker = primary + workerIDStr = strconv.FormatInt(worker.ID, 10) + atomic.AddInt64(&worker.Workload, 1) + } + fileID = track.Telegram.FileID + } + } + if fileID == "" { logStream.Errorf("track %s has no telegram file_id", track.ID) return @@ -297,20 +336,131 @@ func (s *StreamService) streamFromTelegram( } flusher, _ := w.(http.Flusher) - rw := &rangeWriter{ - w: w, - skip: startOffset, - remain: bytesRemaining, - flusher: flusher, + + // MTProto UploadGetFile requires offset to be divisible by 4096 (4KB), + // and no request may cross a 1MB (1048576 byte) boundary. + const maxChunkSize = 512 * 1024 // 512 KB + const oneMB int64 = 1024 * 1024 + chunkOffset := (startOffset / 4096) * 4096 + skipBytes := startOffset - chunkOffset + refreshed := false + + for bytesRemaining > 0 { + select { + case <-ctx.Done(): + return + default: + } + + // Calculate maximum allowed limit up to the next 1MB boundary + bytesToBoundary := oneMB - (chunkOffset % oneMB) + currentLimit := maxChunkSize + if int64(currentLimit) > bytesToBoundary { + currentLimit = int(bytesToBoundary) + } + + req := &tg.UploadGetFileRequest{ + Precise: true, + Location: location, + Offset: chunkOffset, + Limit: currentLimit, + } + + var res tg.UploadFileClass + var fetchErr error + for attempt := 0; attempt < 3; attempt++ { + res, fetchErr = worker.API.UploadGetFile(ctx, req) + if fetchErr == nil { + break + } + if !refreshed && strings.Contains(strings.ToUpper(fetchErr.Error()), "FILE_REFERENCE") { + logStream.Warnf("File reference expired for track %s at offset %d; refreshing...", track.ID, chunkOffset) + if fresh := fetchFresh(); fresh != "" { + if freshDecoded, freshErr := telegram.DecodeFileID(fresh); freshErr == nil { + location = &tg.InputDocumentFileLocation{ + ID: freshDecoded.MediaID, + AccessHash: freshDecoded.AccessHash, + FileReference: freshDecoded.FileReference, + } + req.Location = location + refreshed = true + continue + } + } + } + if errors.Is(fetchErr, context.Canceled) || ctx.Err() != nil { + return + } + time.Sleep(200 * time.Millisecond) + } + + if fetchErr != nil { + logStream.Warnf("UploadGetFile failed after retries for track %s at offset %d: %v", track.ID, chunkOffset, fetchErr) + return + } + + var data []byte + switch f := res.(type) { + case *tg.UploadFile: + data = f.Bytes + default: + logStream.Warnf("unexpected UploadGetFile result type %T for track %s", res, track.ID) + return + } + + rawLen := len(data) + if rawLen == 0 { + // Telegram EOF reached + break + } + + // Handle sub-4KB unaligned start offset on first chunk + if skipBytes > 0 { + if int64(rawLen) <= skipBytes { + skipBytes -= int64(rawLen) + chunkOffset += int64(rawLen) + continue + } + data = data[skipBytes:] + skipBytes = 0 + } + + // Cap data to bytesRemaining + if int64(len(data)) > bytesRemaining { + data = data[:bytesRemaining] + } + + written, writeErr := w.Write(data) + if flusher != nil { + flusher.Flush() + } + if writeErr != nil { + // Client disconnected (e.g. stopped playback, navigated away, or seeked) + return + } + + bytesRemaining -= int64(written) + chunkOffset += int64(rawLen) + + // If Telegram returned fewer bytes than requested limit, it was the final chunk of the file + if rawLen < currentLimit { + break + } } +} - worker := s.tgService.AcquireWorker() - defer s.tgService.ReleaseWorker(worker) +// GetTrack retrieves a track by ID. +func (s *StreamService) GetTrack(ctx context.Context, id string) (*models.Track, error) { + return s.repo.GetByID(ctx, id) +} - downloader := worker.Client.Downloader() - _, err = downloader.Download(worker.API, location).Stream(ctx, rw) - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) { - logStream.Warnf("streaming ended with error for track %s: %v", track.ID, err) +// WarmTrack pre-resolves track information and checks Telegram worker availability. +func (s *StreamService) WarmTrack(ctx context.Context, id string) { + track, err := s.repo.GetByID(ctx, id) + if err != nil || track == nil { + return } + logStream.Infof("Prewarming track %s (%s - %s)", track.ID, track.Audio.Artist, track.Audio.Title) } + diff --git a/internal/services/track_service.go b/internal/services/track_service.go index a5f4f6e..d0910fa 100644 --- a/internal/services/track_service.go +++ b/internal/services/track_service.go @@ -55,12 +55,21 @@ func (s *TrackService) Browse( totalPages = int(math.Ceil(float64(total) / float64(perPage))) } + coverURL := "" + for _, item := range items { + if item.CoverURL != "" { + coverURL = item.CoverURL + break + } + } + return &models.BrowseResponse{ Items: items, Total: total, Page: page, PerPage: perPage, TotalPages: totalPages, + CoverURL: coverURL, }, nil } @@ -94,17 +103,23 @@ func (s *TrackService) GetRandom(ctx context.Context, limit int, channelID int64 return items, nil } -// GetTopics returns aggregated topic lists with counts. +// GetTopics returns aggregated topic lists with counts matching Api/schemas/topics.py. func (s *TrackService) GetTopics(ctx context.Context, limit int) (*models.TopicsResponse, error) { topics, err := s.repo.GetTopics(ctx, limit) if err != nil { return nil, err } + topicNames := make([]string, 0, len(topics)) + for _, t := range topics { + topicNames = append(topicNames, t.TopicName) + } + return &models.TopicsResponse{ - OK: true, - Total: len(topics), - Items: topics, + OK: true, + Total: len(topics), + Items: topics, + Topics: topicNames, }, nil } diff --git a/internal/telegram/client.go b/internal/telegram/client.go index fc8c6c4..625519e 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -5,44 +5,50 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "strconv" "strings" "sync" "sync/atomic" "time" + "github.com/gotd/td/session" "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/downloader" "github.com/gotd/td/tg" "streamgo/internal/config" "streamgo/internal/logger" + "streamgo/internal/models" ) var log = logger.New("telegram") // ClientWorker represents a single authenticated MTProto client connection. type ClientWorker struct { - ID int64 - FirstName string - Username string - Bot bool - Token string - Client *telegram.Client - API *tg.Client - Downloader *downloader.Downloader - Workload int64 - Ready bool + ID int64 + FirstName string + Username string + Bot bool + Token string + Client *telegram.Client + API *tg.Client + Downloader *downloader.Downloader + Workload int64 + Ready bool + channelAccessHashes map[int64]int64 + channelAccessMu sync.RWMutex } -// Service encapsulates the MTProto multi-client pool and lifecycle. +// Service manages a pool of concurrent MTProto Telegram bot clients. type Service struct { - Config *config.Config - primaryWorker *ClientWorker - workers []*ClientWorker - mu sync.RWMutex - stopCancel context.CancelFunc - Dispatcher tg.UpdateDispatcher + Config *config.Config + Dispatcher tg.UpdateDispatcher + primaryWorker *ClientWorker + workers []*ClientWorker + mu sync.RWMutex + stopCancel context.CancelFunc } // New creates an unstarted Telegram multi-client pool. @@ -59,35 +65,48 @@ func New(cfg *config.Config) (*Service, error) { Dispatcher: dispatcher, } - // 1. Create Primary Worker + sessionDir := "stream_media/sessions" + _ = os.MkdirAll(sessionDir, 0700) + + // 1. Create Primary Worker with persistent session storage + primaryTokenPrefix := strings.Split(cfg.BotToken, ":")[0] + primarySessionPath := filepath.Join(sessionDir, fmt.Sprintf("session_%s.json", primaryTokenPrefix)) + primaryClient := telegram.NewClient(cfg.ApiID, cfg.ApiHash, telegram.Options{ - NoUpdates: false, - UpdateHandler: dispatcher, + NoUpdates: false, + UpdateHandler: dispatcher, + SessionStorage: &session.FileStorage{Path: primarySessionPath}, }) svc.primaryWorker = &ClientWorker{ - Token: cfg.BotToken, - Client: primaryClient, - API: primaryClient.API(), - Downloader: downloader.NewDownloader(), + Token: cfg.BotToken, + Client: primaryClient, + API: primaryClient.API(), + Downloader: downloader.NewDownloader(), + channelAccessHashes: make(map[int64]int64), } svc.workers = append(svc.workers, svc.primaryWorker) - // 2. Create Secondary Multi-Client Workers + // 2. Create Secondary Multi-Client Workers with persistent session storage if cfg.MultiClients { for i, tok := range cfg.MultiClientTokens { tok = strings.TrimSpace(tok) if tok == "" || tok == cfg.BotToken { continue } + tokenPrefix := strings.Split(tok, ":")[0] + sessionPath := filepath.Join(sessionDir, fmt.Sprintf("session_%s.json", tokenPrefix)) + workerClient := telegram.NewClient(cfg.ApiID, cfg.ApiHash, telegram.Options{ - NoUpdates: true, // Secondary download workers don't need update processing + NoUpdates: true, // Secondary download workers don't need update processing + SessionStorage: &session.FileStorage{Path: sessionPath}, }) worker := &ClientWorker{ - Token: tok, - Client: workerClient, - API: workerClient.API(), - Downloader: downloader.NewDownloader(), + Token: tok, + Client: workerClient, + API: workerClient.API(), + Downloader: downloader.NewDownloader(), + channelAccessHashes: make(map[int64]int64), } svc.workers = append(svc.workers, worker) log.Infof("Registered multi-client worker #%d", i+1) @@ -249,6 +268,15 @@ func (s *Service) IsReady() bool { return s.primaryWorker != nil && s.primaryWorker.Ready } +// Workers returns a snapshot slice of all registered ClientWorkers. +func (s *Service) Workers() []*ClientWorker { + s.mu.RLock() + defer s.mu.RUnlock() + res := make([]*ClientWorker, len(s.workers)) + copy(res, s.workers) + return res +} + // Self returns primary bot user info. func (s *Service) Self() *tg.User { s.mu.RLock() @@ -354,3 +382,332 @@ func (s *Service) DownloadPartialByFileID(ctx context.Context, fileID string, ma return s.DownloadPartial(ctx, location, maxBytes, w) } + +func normalizeToChannelID(chatID int64) (int64, bool) { + if chatID <= -1000000000000 { + return -chatID - 1000000000000, true + } + if chatID > 1000000000 { + return chatID, true + } + return chatID, false +} + +func (w *ClientWorker) getChannelAccessHash(channelID int64) int64 { + w.channelAccessMu.RLock() + defer w.channelAccessMu.RUnlock() + if w.channelAccessHashes == nil { + return 0 + } + return w.channelAccessHashes[channelID] +} + +func (w *ClientWorker) setChannelAccessHash(channelID, accessHash int64) { + w.channelAccessMu.Lock() + defer w.channelAccessMu.Unlock() + if w.channelAccessHashes == nil { + w.channelAccessHashes = make(map[int64]int64) + } + w.channelAccessHashes[channelID] = accessHash +} + +func extractMessagesList(msgs tg.MessagesMessagesClass) []tg.MessageClass { + if msgs == nil { + return nil + } + switch m := msgs.(type) { + case *tg.MessagesChannelMessages: + return m.Messages + case *tg.MessagesMessages: + return m.Messages + case *tg.MessagesMessagesSlice: + return m.Messages + default: + return nil + } +} + +// FetchFileIDForMessage fetches a message from chatID/msgID and generates this worker's distinct file_id. +func (w *ClientWorker) FetchFileIDForMessage(ctx context.Context, chatID int64, msgID int32) (string, error) { + if !w.Ready || w.API == nil { + return "", errors.New("worker is not ready") + } + + channelID, isChannel := normalizeToChannelID(chatID) + if isChannel { + accessHash := w.getChannelAccessHash(channelID) + if accessHash == 0 { + // Try MessagesGetChats first (uses raw int64 channel IDs without needing prior access hash) + chats, err := w.API.MessagesGetChats(ctx, []int64{channelID}) + if err == nil { + for _, chat := range chats.GetChats() { + if ch, ok := chat.(*tg.Channel); ok && ch.ID == channelID { + accessHash = ch.AccessHash + w.setChannelAccessHash(channelID, accessHash) + break + } + } + } + if accessHash == 0 { + channels, err := w.API.ChannelsGetChannels(ctx, []tg.InputChannelClass{ + &tg.InputChannel{ChannelID: channelID}, + }) + if err == nil { + for _, chat := range channels.GetChats() { + if ch, ok := chat.(*tg.Channel); ok && ch.ID == channelID { + accessHash = ch.AccessHash + w.setChannelAccessHash(channelID, accessHash) + break + } + } + } + } + } + + req := &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ + ChannelID: channelID, + AccessHash: accessHash, + }, + ID: []tg.InputMessageClass{ + &tg.InputMessageID{ID: int(msgID)}, + }, + } + + msgs, err := w.API.ChannelsGetMessages(ctx, req) + if err != nil { + return "", fmt.Errorf("channels get messages: %w", err) + } + + for _, m := range extractMessagesList(msgs) { + if msg, ok := m.(*tg.Message); ok { + if mediaDoc, ok := msg.Media.(*tg.MessageMediaDocument); ok { + if doc, ok := mediaDoc.Document.(*tg.Document); ok { + return EncodeFileID(doc.ID, doc.AccessHash, int32(doc.DCID), doc.FileReference), nil + } + } + } + } + return "", errors.New("no document found in channel message") + } + + // Non-channel chats + msgs, err := w.API.MessagesGetMessages(ctx, []tg.InputMessageClass{ + &tg.InputMessageID{ID: int(msgID)}, + }) + if err != nil { + return "", fmt.Errorf("messages get messages: %w", err) + } + + for _, m := range extractMessagesList(msgs) { + if msg, ok := m.(*tg.Message); ok { + if mediaDoc, ok := msg.Media.(*tg.MessageMediaDocument); ok { + if doc, ok := mediaDoc.Document.(*tg.Document); ok { + return EncodeFileID(doc.ID, doc.AccessHash, int32(doc.DCID), doc.FileReference), nil + } + } + } + } + return "", errors.New("no document found in message") +} + +// SyncFileIDs collects file_ids for all ready workers for a given message. +func (s *Service) SyncFileIDs(ctx context.Context, chatID int64, msgID int32, fallbackChatID int64, fallbackMsgID int32) map[string]string { + workers := s.Workers() + if len(workers) == 0 { + return map[string]string{} + } + + type syncResult struct { + botID string + fileID string + } + + results := make(chan syncResult, len(workers)) + var wg sync.WaitGroup + + for _, w := range workers { + if !w.Ready { + continue + } + worker := w + wg.Add(1) + go func() { + defer wg.Done() + botID := strconv.FormatInt(worker.ID, 10) + if botID == "0" || botID == "" { + if parts := strings.Split(worker.Token, ":"); len(parts) > 0 { + botID = parts[0] + } + } + if botID == "0" || botID == "" { + return + } + + // Try primary chat ID & msg ID first + fid, err := worker.FetchFileIDForMessage(ctx, chatID, msgID) + if (err != nil || fid == "") && fallbackChatID != 0 && fallbackMsgID != 0 { + fid, err = worker.FetchFileIDForMessage(ctx, fallbackChatID, fallbackMsgID) + } + + if err == nil && fid != "" { + results <- syncResult{botID: botID, fileID: fid} + } + }() + } + + wg.Wait() + close(results) + + res := make(map[string]string) + for r := range results { + res[r.botID] = r.fileID + } + return res +} + +// DownloadPartialForTrack resiliently downloads a chunk of the track, matching workers and auto-refreshing expired file references. +func (s *Service) DownloadPartialForTrack( + ctx context.Context, + track *models.Track, + maxBytes int64, + w io.Writer, + onRefreshed func(botID, fileID string), +) error { + if track == nil { + return errors.New("track is nil") + } + + readyWorkers := []*ClientWorker{} + s.mu.RLock() + for _, worker := range s.workers { + if worker.Ready { + readyWorkers = append(readyWorkers, worker) + } + } + s.mu.RUnlock() + + if len(readyWorkers) == 0 { + return errors.New("no ready telegram workers available") + } + + var chosenWorker *ClientWorker + var fileID string + + // 1. Prefer a worker that already has a mapped file_id + if track.Telegram.FileIDs != nil { + for _, worker := range readyWorkers { + wid := strconv.FormatInt(worker.ID, 10) + if wid == "0" || wid == "" { + if parts := strings.Split(worker.Token, ":"); len(parts) > 0 { + wid = parts[0] + } + } + if fid, ok := track.Telegram.FileIDs[wid]; ok && fid != "" { + chosenWorker = worker + fileID = fid + atomic.AddInt64(&worker.Workload, 1) + break + } + } + } + + // 2. If not matched, pick the least-loaded worker + if chosenWorker == nil { + chosenWorker = s.AcquireWorker() + } + defer s.ReleaseWorker(chosenWorker) + + wid := strconv.FormatInt(chosenWorker.ID, 10) + if wid == "0" || wid == "" { + if parts := strings.Split(chosenWorker.Token, ":"); len(parts) > 0 { + wid = parts[0] + } + } + + if fileID == "" && track.Telegram.FileIDs != nil { + fileID = track.Telegram.FileIDs[wid] + } + + fetchFresh := func() (string, error) { + // Try cache channel first + if track.CacheChatID != 0 && track.CacheMessageID != 0 { + f, err := chosenWorker.FetchFileIDForMessage(ctx, track.CacheChatID, track.CacheMessageID) + if err == nil && f != "" { + return f, nil + } + } + // Fallback to source chat + if track.SourceChatID != 0 && track.SourceMessageID != 0 { + f, err := chosenWorker.FetchFileIDForMessage(ctx, track.SourceChatID, track.SourceMessageID) + if err == nil && f != "" { + return f, nil + } + } + return "", errors.New("could not resolve message to fetch fresh file_id") + } + + if fileID == "" { + if fresh, err := fetchFresh(); err == nil && fresh != "" { + fileID = fresh + if onRefreshed != nil { + onRefreshed(wid, fileID) + } + } else { + fileID = track.Telegram.FileID + } + } + + if fileID == "" { + return errors.New("no file_id available for track") + } + + doDownload := func(fid string) error { + decoded, err := DecodeFileID(fid) + if err != nil { + return fmt.Errorf("failed to decode file_id: %w", err) + } + location := &tg.InputDocumentFileLocation{ + ID: decoded.MediaID, + AccessHash: decoded.AccessHash, + FileReference: decoded.FileReference, + } + + pw := &partialWriter{ + w: w, + remain: maxBytes, + } + downloader := chosenWorker.Client.Downloader() + _, dlErr := downloader.Download(chosenWorker.API, location).Stream(ctx, pw) + if dlErr != nil && !errors.Is(dlErr, io.EOF) && !errors.Is(dlErr, context.Canceled) { + return dlErr + } + return nil + } + + err := doDownload(fileID) + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "FILE_REFERENCE") || strings.Contains(errStr, "400") { + log.Warnf("File reference expired for track %s on worker %s; refreshing from cache channel...", track.ID, wid) + if fresh, refreshErr := fetchFresh(); refreshErr == nil && fresh != "" { + fileID = fresh + if onRefreshed != nil { + onRefreshed(wid, fileID) + } + // Reset writer if seeker + if seeker, ok := w.(io.WriteSeeker); ok { + _, _ = seeker.Seek(0, io.SeekStart) + } + if trunc, ok := w.(interface{ Truncate(int64) error }); ok { + _ = trunc.Truncate(0) + } + err = doDownload(fileID) + } + } + } + + return err +} + + diff --git a/internal/telegram/listener.go b/internal/telegram/listener.go index c535cfc..1cd9bc3 100644 --- a/internal/telegram/listener.go +++ b/internal/telegram/listener.go @@ -34,6 +34,7 @@ type IngestionListener struct { accessFilter AccessFilter dedupChecker DedupChecker onNewTrack func(trackID string) + tgService *Service } // NewIngestionListener creates a new IngestionListener. @@ -57,6 +58,11 @@ func NewIngestionListener( } } +// SetTelegramService attaches the telegram multi-client service to the listener. +func (l *IngestionListener) SetTelegramService(svc *Service) { + l.tgService = svc +} + // SetupDispatcher configures an update dispatcher to intercept audio uploads. func (l *IngestionListener) SetupDispatcher(d *tg.UpdateDispatcher) { d.OnNewChannelMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateNewChannelMessage) error { @@ -226,6 +232,16 @@ func (l *IngestionListener) handleMessage(ctx context.Context, msg *tg.Message) if botIDKey != "" { fileIDs[botIDKey] = primaryFileID } + if l.tgService != nil { + syncCtx, cancel := context.WithTimeout(ctx, 4*time.Second) + synced := l.tgService.SyncFileIDs(syncCtx, sourceChatID, sourceMsgID, cacheChatID, cacheMsgID) + cancel() + for k, v := range synced { + if v != "" { + fileIDs[k] = v + } + } + } var artists []string if artist != "" { @@ -304,14 +320,14 @@ func buildFingerprint(title, artist, album string, durSec int32) string { al := normalizeListenerText(album) durKey := "" if durSec > 0 { - durKey = fmt.Sprintf("%d", (durSec/2)*2) + durKey = fmt.Sprintf("%d", int64(math.Round(float64(durSec)/2.0)*2)) } return strings.Trim(strings.Join([]string{t, a, al, durKey}, "|"), "|") } func normalizeListenerText(val string) string { s := strings.ToLower(val) - reNonAlpha := regexp.MustCompile(`[^a-z0-9]+`) + reNonAlpha := regexp.MustCompile(`[^\p{L}\p{N}]+`) s = reNonAlpha.ReplaceAllString(s, " ") return strings.TrimSpace(s) } diff --git a/scripts/verify_endpoints.go b/scripts/verify_endpoints.go deleted file mode 100644 index 7266448..0000000 --- a/scripts/verify_endpoints.go +++ /dev/null @@ -1,165 +0,0 @@ -package main - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strconv" - "time" -) - -func main() { - botToken := "8879601665:AAHWVfu6ckG3XPgePUe_RCwzpeT8SpmOZhI" - baseURL := "http://localhost:8000" - - fmt.Println("=== 1. Testing Health ===") - resp, err := http.Get(baseURL + "/health") - if err != nil { - panic(err) - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - fmt.Printf("/health: %s\n\n", body) - - fmt.Println("=== 2. Testing Telegram WebApp Auth ===") - authDate := strconv.FormatInt(time.Now().Unix(), 10) - userJSON := `{"id":999888777,"first_name":"GoTester","last_name":"Stream","username":"gotester"}` - dataCheckString := fmt.Sprintf("auth_date=%s\nuser=%s", authDate, userJSON) - - hSecret := hmac.New(sha256.New, []byte("WebAppData")) - hSecret.Write([]byte(botToken)) - secretKey := hSecret.Sum(nil) - - hHash := hmac.New(sha256.New, secretKey) - hHash.Write([]byte(dataCheckString)) - calcHash := hex.EncodeToString(hHash.Sum(nil)) - - initData := fmt.Sprintf("auth_date=%s&user=%s&hash=%s", - url.QueryEscape(authDate), - url.QueryEscape(userJSON), - calcHash, - ) - - authPayload, _ := json.Marshal(map[string]string{"init_data": initData}) - authResp, err := http.Post(baseURL+"/auth/telegram", "application/json", bytes.NewReader(authPayload)) - if err != nil { - panic(err) - } - var authResult struct { - OK bool `json:"ok"` - Token string `json:"token"` - User struct { - ID int64 `json:"id"` - FirstName string `json:"first_name"` - } `json:"user"` - } - json.NewDecoder(authResp.Body).Decode(&authResult) - authResp.Body.Close() - fmt.Printf("Auth Success: %v, Token: %s, User: %s (ID: %d)\n\n", authResult.OK, authResult.Token[:25]+"...", authResult.User.FirstName, authResult.User.ID) - - token := authResult.Token - - fmt.Println("=== 3. Testing GET /auth/me ===") - req, _ := http.NewRequest("GET", baseURL+"/auth/me", nil) - req.Header.Set("Authorization", "Bearer "+token) - meResp, err := http.DefaultClient.Do(req) - if err != nil { - panic(err) - } - meBody, _ := io.ReadAll(meResp.Body) - meResp.Body.Close() - fmt.Printf("/auth/me: %s\n\n", meBody) - - fmt.Println("=== 4. Testing Favourites (Add, List, Check, Remove) ===") - favPayload, _ := json.Marshal(map[string]string{"track_id": "AgADjx8AAtO9YFU"}) - favReq, _ := http.NewRequest("POST", baseURL+"/favourites", bytes.NewReader(favPayload)) - favReq.Header.Set("Authorization", "Bearer "+token) - favReq.Header.Set("Content-Type", "application/json") - favResp, err := http.DefaultClient.Do(favReq) - if err != nil { - panic(err) - } - favBody, _ := io.ReadAll(favResp.Body) - favResp.Body.Close() - fmt.Printf("POST /favourites: %s\n", favBody) - - listFavReq, _ := http.NewRequest("GET", baseURL+"/favourites", nil) - listFavReq.Header.Set("Authorization", "Bearer "+token) - listFavResp, _ := http.DefaultClient.Do(listFavReq) - listFavBody, _ := io.ReadAll(listFavResp.Body) - listFavResp.Body.Close() - fmt.Printf("GET /favourites: %s\n", listFavBody) - - idsFavReq, _ := http.NewRequest("GET", baseURL+"/favourites/ids", nil) - idsFavReq.Header.Set("Authorization", "Bearer "+token) - idsFavResp, _ := http.DefaultClient.Do(idsFavReq) - idsFavBody, _ := io.ReadAll(idsFavResp.Body) - idsFavResp.Body.Close() - fmt.Printf("GET /favourites/ids: %s\n", idsFavBody) - - delFavReq, _ := http.NewRequest("DELETE", baseURL+"/favourites/AgADjx8AAtO9YFU", nil) - delFavReq.Header.Set("Authorization", "Bearer "+token) - delFavResp, _ := http.DefaultClient.Do(delFavReq) - delFavBody, _ := io.ReadAll(delFavResp.Body) - delFavResp.Body.Close() - fmt.Printf("DELETE /favourites/AgADjx8AAtO9YFU: %s\n\n", delFavBody) - - fmt.Println("=== 5. Testing Playlists (Create, Add Tracks, Detail, Delete) ===") - plPayload, _ := json.Marshal(map[string]string{ - "title": "Live Verification Playlist", - "description": "Created by Go test suite", - }) - plReq, _ := http.NewRequest("POST", baseURL+"/playlists", bytes.NewReader(plPayload)) - plReq.Header.Set("Authorization", "Bearer "+token) - plReq.Header.Set("Content-Type", "application/json") - plResp, err := http.DefaultClient.Do(plReq) - if err != nil { - panic(err) - } - var createdPL struct { - ID string `json:"_id"` - Title string `json:"title"` - } - json.NewDecoder(plResp.Body).Decode(&createdPL) - plResp.Body.Close() - fmt.Printf("Created Playlist: ID=%s, Title=%s\n", createdPL.ID, createdPL.Title) - - // Add track to playlist - addTrkPayload, _ := json.Marshal(map[string]string{"track_id": "AgADjx8AAtO9YFU"}) - addTrkReq, _ := http.NewRequest("POST", baseURL+"/playlists/"+createdPL.ID+"/tracks", bytes.NewReader(addTrkPayload)) - addTrkReq.Header.Set("Authorization", "Bearer "+token) - addTrkReq.Header.Set("Content-Type", "application/json") - addTrkResp, _ := http.DefaultClient.Do(addTrkReq) - addTrkBody, _ := io.ReadAll(addTrkResp.Body) - addTrkResp.Body.Close() - fmt.Printf("POST /playlists/{id}/tracks: %s\n", addTrkBody) - - // Get playlist detail - getPLReq, _ := http.NewRequest("GET", baseURL+"/playlists/"+createdPL.ID, nil) - getPLResp, _ := http.DefaultClient.Do(getPLReq) - getPLBody, _ := io.ReadAll(getPLResp.Body) - getPLResp.Body.Close() - fmt.Printf("GET /playlists/{id}: %s\n", getPLBody) - - // Delete playlist - delPLReq, _ := http.NewRequest("DELETE", baseURL+"/playlists/"+createdPL.ID, nil) - delPLReq.Header.Set("Authorization", "Bearer "+token) - delPLResp, _ := http.DefaultClient.Do(delPLReq) - delPLBody, _ := io.ReadAll(delPLResp.Body) - delPLResp.Body.Close() - fmt.Printf("DELETE /playlists/{id}: %s\n\n", delPLBody) - - fmt.Println("=== 6. Testing Cover HEAD and GET ===") - headCoverResp, _ := http.Head(baseURL + "/cover/AgADjx8AAtO9YFU") - fmt.Printf("HEAD /cover/AgADjx8AAtO9YFU: Status %d, Location: %s\n", headCoverResp.StatusCode, headCoverResp.Header.Get("Location")) - - fmt.Println("\n>>> ALL TESTS COMPLETED SUCCESSFULLY! <<<") - os.Exit(0) -} From a8d7670f5d91efea849ae8b5f96a733ea2c1a0bd Mon Sep 17 00:00:00 2001 From: Kousik Date: Sat, 19 Sep 2026 18:40:34 +0530 Subject: [PATCH 09/39] misc changes --- internal/api/handlers/albums_test.go | 269 ----------- .../history_daily_access_share_test.go | 436 ------------------ internal/api/handlers/media_extra.go | 173 ++++++- .../playlists_favourites_discord_test.go | 412 ----------------- internal/api/handlers/sources_test.go | 143 ------ internal/repository/track_repo.go | 83 +++- internal/server/router.go | 3 +- internal/services/favourite_playlist_test.go | 4 + .../services/lyrics_enrichment_service.go | 200 +++++++- 9 files changed, 430 insertions(+), 1293 deletions(-) delete mode 100644 internal/api/handlers/albums_test.go delete mode 100644 internal/api/handlers/history_daily_access_share_test.go delete mode 100644 internal/api/handlers/playlists_favourites_discord_test.go delete mode 100644 internal/api/handlers/sources_test.go diff --git a/internal/api/handlers/albums_test.go b/internal/api/handlers/albums_test.go deleted file mode 100644 index 89225bd..0000000 --- a/internal/api/handlers/albums_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - - "streamgo/internal/models" - "streamgo/internal/services" -) - -type mockArtistAlbumRepoForTest struct { - albums []*models.Album - artists []*models.Artist - tracks []*models.Track -} - -func (m *mockArtistAlbumRepoForTest) ListArtists(ctx context.Context, page, perPage int, refresh bool) ([]*models.Artist, int64, error) { - return m.artists, int64(len(m.artists)), nil -} - -func (m *mockArtistAlbumRepoForTest) RefreshArtistsCache(ctx context.Context, limitTracks, limitArtists int) (int, error) { - return len(m.artists), nil -} - -func (m *mockArtistAlbumRepoForTest) GetArtistByID(ctx context.Context, id string) (*models.Artist, error) { - for _, a := range m.artists { - if a.ID == id || a.Name == id { - return a, nil - } - } - return nil, nil -} - -func (m *mockArtistAlbumRepoForTest) GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) { - return m.tracks, nil -} - -func (m *mockArtistAlbumRepoForTest) GetArtistTracksPaginated(ctx context.Context, artistName string, page, perPage int) ([]*models.Track, int64, error) { - return m.tracks, int64(len(m.tracks)), nil -} - -func (m *mockArtistAlbumRepoForTest) GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) { - return m.albums, nil -} - -func (m *mockArtistAlbumRepoForTest) ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) ([]*models.Album, int64, error) { - return m.albums, int64(len(m.albums)), nil -} - -func (m *mockArtistAlbumRepoForTest) GetAlbumByID(ctx context.Context, id string) (*models.Album, error) { - for _, a := range m.albums { - if a.ID == id { - return a, nil - } - } - return nil, nil -} - -func (m *mockArtistAlbumRepoForTest) GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) { - return m.tracks, nil -} - -func (m *mockArtistAlbumRepoForTest) GetAlbumTracksPaginated(ctx context.Context, albumID string, page, perPage int) ([]*models.Track, int64, error) { - return m.tracks, int64(len(m.tracks)), nil -} - -func (m *mockArtistAlbumRepoForTest) RefreshAlbumsCache(ctx context.Context, limit int) (int, error) { - return len(m.albums), nil -} - -func TestAlbumsAndArtistsPythonResponseShapes(t *testing.T) { - repo := &mockArtistAlbumRepoForTest{ - albums: []*models.Album{ - { - ID: "album_delirium_deluxe_2015", - Id: "album_delirium_deluxe_2015", - Title: "Delirium (Deluxe)", - Artist: "Ellie Goulding", - TracksCount: 1, - TrackCount: 1, - Year: 2015, - CoverURL: "https://example.com/cover.jpg", - }, - }, - artists: []*models.Artist{ - { - ID: "artist_ellie_goulding", - Id: "artist_ellie_goulding", - Name: "Ellie Goulding", - TracksCount: 1, - TrackCount: 1, - CoverURL: "https://example.com/avatar.jpg", - }, - }, - tracks: []*models.Track{ - { - ID: "AgADkAYAAr1QcEU", - Audio: models.AudioMeta{ - Title: "Love Me Like You Do", - Artist: "Ellie Goulding", - Album: "Delirium (Deluxe)", - AlbumID: "album_delirium_deluxe_2015", - DurationSec: 252, - }, - }, - }, - } - - svc := services.NewArtistAlbumService(repo, nil) - albumHandler := NewAlbumHandler(svc) - artistHandler := NewArtistHandler(svc) - - r := chi.NewRouter() - albumHandler.Routes(r) - artistHandler.Routes(r) - - // 1. Test GET /albums - t.Run("GET /albums response matches Python schema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/albums?limit=200", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode json: %v", err) - } - - if resp["ok"] != true { - t.Errorf("expected ok=true, got %v", resp["ok"]) - } - if int(resp["per_page"].(float64)) != 200 { - t.Errorf("expected per_page=200, got %v", resp["per_page"]) - } - if int(resp["page"].(float64)) != 1 { - t.Errorf("expected page=1, got %v", resp["page"]) - } - if int(resp["total"].(float64)) != 1 { - t.Errorf("expected total=1, got %v", resp["total"]) - } - - items, ok := resp["items"].([]any) - if !ok || len(items) != 1 { - t.Fatalf("expected 1 item, got %v", resp["items"]) - } - first := items[0].(map[string]any) - if first["id"] != "album_delirium_deluxe_2015" || first["_id"] != "album_delirium_deluxe_2015" { - t.Errorf("expected id and _id to match, got id=%v, _id=%v", first["id"], first["_id"]) - } - if first["title"] != "Delirium (Deluxe)" { - t.Errorf("expected title='Delirium (Deluxe)', got %v", first["title"]) - } - }) - - // 2. Test GET /albums/{id} - t.Run("GET /albums/{id} matches Python schema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/albums/album_delirium_deluxe_2015", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode json: %v", err) - } - - if resp["ok"] != true { - t.Errorf("expected ok=true, got %v", resp["ok"]) - } - if resp["album"] == nil { - t.Errorf("expected album object, got nil") - } - if resp["tracks"] == nil { - t.Errorf("expected tracks array, got nil") - } - }) - - // 3. Test GET /albums/{id}/tracks - t.Run("GET /albums/{id}/tracks matches Python schema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/albums/album_delirium_deluxe_2015/tracks", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode json: %v", err) - } - - if resp["ok"] != true { - t.Errorf("expected ok=true, got %v", resp["ok"]) - } - if int(resp["page"].(float64)) != 1 { - t.Errorf("expected page=1, got %v", resp["page"]) - } - if resp["items"] == nil { - t.Errorf("expected items array, got nil") - } - }) - - // 4. Test GET /artists - t.Run("GET /artists matches Python schema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/artists?limit=200", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode json: %v", err) - } - - if resp["ok"] != true { - t.Errorf("expected ok=true, got %v", resp["ok"]) - } - if int(resp["per_page"].(float64)) != 200 { - t.Errorf("expected per_page=200, got %v", resp["per_page"]) - } - if resp["items"] == nil { - t.Errorf("expected items array, got nil") - } - }) - - // 5. Test GET /artists/{id} - t.Run("GET /artists/{id} matches Python schema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/artists/artist_ellie_goulding", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode json: %v", err) - } - - if resp["ok"] != true { - t.Errorf("expected ok=true, got %v", resp["ok"]) - } - if resp["artist"] == nil { - t.Errorf("expected artist object, got nil") - } - if resp["popular_tracks"] == nil { - t.Errorf("expected popular_tracks object, got nil") - } - if resp["releases"] == nil { - t.Errorf("expected releases object, got nil") - } - }) -} diff --git a/internal/api/handlers/history_daily_access_share_test.go b/internal/api/handlers/history_daily_access_share_test.go deleted file mode 100644 index 86e52be..0000000 --- a/internal/api/handlers/history_daily_access_share_test.go +++ /dev/null @@ -1,436 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/go-chi/chi/v5" - - "streamgo/internal/config" - "streamgo/internal/models" - "streamgo/internal/services" -) - -// Mock History Repo -type mockHistRepo struct { - events []models.ListeningEventItem - history []string - topPlays []string -} - -func (m *mockHistRepo) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { - m.history = append([]string{trackID}, m.history...) - return nil -} - -func (m *mockHistRepo) GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) { - return m.history, nil -} - -func (m *mockHistRepo) GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) { - return m.topPlays, nil -} - -func (m *mockHistRepo) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { - m.events = append(m.events, events...) - for _, e := range events { - m.history = append([]string{e.TrackID}, m.history...) - } - return nil -} - -// Mock Daily Playlist Repo -type mockDailyRepo struct { - cache map[string]*models.DailyPlaylistDoc -} - -func (m *mockDailyRepo) GetCachedDailyPlaylist(ctx context.Context, docID string) (*models.DailyPlaylistDoc, error) { - return m.cache[docID], nil -} - -func (m *mockDailyRepo) SaveDailyPlaylist(ctx context.Context, doc *models.DailyPlaylistDoc) error { - if m.cache == nil { - m.cache = make(map[string]*models.DailyPlaylistDoc) - } - m.cache[doc.ID] = doc - return nil -} - -// Mock Access Control Repo -type mockAccessRepo struct { - policy *models.AccessPolicy - invites map[string]*models.InviteCode - allowlist map[int64]*models.AllowlistEntry - bypass map[int64]*models.AllowlistEntry - users map[int64]*models.User -} - -func newMockAccessRepo() *mockAccessRepo { - return &mockAccessRepo{ - policy: &models.AccessPolicy{ - RegistrationMode: "open", - EnforceMembership: false, - RequiredChats: []models.RequiredChat{}, - }, - invites: make(map[string]*models.InviteCode), - allowlist: make(map[int64]*models.AllowlistEntry), - bypass: make(map[int64]*models.AllowlistEntry), - users: make(map[int64]*models.User), - } -} - -func (m *mockAccessRepo) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { - return m.policy, nil -} - -func (m *mockAccessRepo) SavePolicy(ctx context.Context, policy *models.AccessPolicy) error { - m.policy = policy - return nil -} - -func (m *mockAccessRepo) CreateInvite(ctx context.Context, invite *models.InviteCode) error { - m.invites[invite.Code] = invite - return nil -} - -func (m *mockAccessRepo) GetInvite(ctx context.Context, code string) (*models.InviteCode, error) { - return m.invites[code], nil -} - -func (m *mockAccessRepo) ConsumeInvite(ctx context.Context, code string) error { - if inv := m.invites[code]; inv != nil { - inv.UsedCount++ - } - return nil -} - -func (m *mockAccessRepo) RevokeInvite(ctx context.Context, code string) bool { - if inv := m.invites[code]; inv != nil { - inv.Revoked = true - return true - } - return false -} - -func (m *mockAccessRepo) ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) { - var list []*models.InviteCode - for _, inv := range m.invites { - if includeDead || (!inv.Revoked) { - list = append(list, inv) - } - } - return list, nil -} - -func (m *mockAccessRepo) AddToAllowlist(ctx context.Context, entry *models.AllowlistEntry) error { - m.allowlist[entry.UserID] = entry - return nil -} - -func (m *mockAccessRepo) RemoveFromAllowlist(ctx context.Context, userID int64) bool { - if _, ok := m.allowlist[userID]; ok { - delete(m.allowlist, userID) - return true - } - return false -} - -func (m *mockAccessRepo) IsInAllowlist(ctx context.Context, userID int64) bool { - _, ok := m.allowlist[userID] - return ok -} - -func (m *mockAccessRepo) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { - var list []*models.AllowlistEntry - for _, e := range m.allowlist { - list = append(list, e) - } - return list, nil -} - -func (m *mockAccessRepo) AddToBypass(ctx context.Context, entry *models.AllowlistEntry) error { - m.bypass[entry.UserID] = entry - return nil -} - -func (m *mockAccessRepo) RemoveFromBypass(ctx context.Context, userID int64) bool { - if _, ok := m.bypass[userID]; ok { - delete(m.bypass, userID) - return true - } - return false -} - -func (m *mockAccessRepo) IsInBypass(ctx context.Context, userID int64) bool { - _, ok := m.bypass[userID] - return ok -} - -func (m *mockAccessRepo) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { - var list []*models.AllowlistEntry - for _, e := range m.bypass { - list = append(list, e) - } - return list, nil -} - -func (m *mockAccessRepo) ListUsers(ctx context.Context, query string, status string, limit int, skip int) ([]*models.User, int64, error) { - var list []*models.User - for _, u := range m.users { - list = append(list, u) - } - return list, int64(len(list)), nil -} - -func (m *mockAccessRepo) SetUserStatus(ctx context.Context, userID int64, status string, reason string) error { - if u := m.users[userID]; u != nil { - u.Status = status - } - return nil -} - -func (m *mockAccessRepo) RevokeUserSessions(ctx context.Context, userID int64) (int, error) { - if u := m.users[userID]; u != nil { - u.TokenVersion++ - return u.TokenVersion, nil - } - return 1, nil -} - -func TestHistoryHandler(t *testing.T) { - cfg := &config.Config{GuestPassword: "testguestpassword"} - userRepo := &mockUserRepoForHandler{} - authSvc := services.NewAuthService(cfg, userRepo) - - trackRepo := &mockTrackRepoForHandler{} - histRepo := &mockHistRepo{ - topPlays: []string{"trk_1"}, - } - histSvc := services.NewHistoryService(histRepo, trackRepo) - handler := NewHistoryHandler(histSvc, authSvc) - - r := chi.NewRouter() - handler.Routes(r) - - // Create test user and token - user := &models.User{ID: 100, Username: "testuser"} - token, _ := authSvc.CreateToken(user) - - // 1. Post listening event - eventPayload := map[string]any{ - "events": []map[string]any{ - { - "id": "ev_1", - "track_id": "trk_1", - "played_at": float64(time.Now().Unix()), - }, - }, - } - b, _ := json.Marshal(eventPayload) - req := httptest.NewRequest("POST", "/me/listening-events", bytes.NewReader(b)) - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for listening events, got %d: %s", rec.Code, rec.Body.String()) - } - - // 2. Get history - req = httptest.NewRequest("GET", "/history", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for /history, got %d", rec.Code) - } - - // 3. Get top played - req = httptest.NewRequest("GET", "/me/top-played", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for /me/top-played, got %d", rec.Code) - } -} - -func TestDailyPlaylistHandler(t *testing.T) { - cfg := &config.Config{GuestPassword: "testguestpassword"} - userRepo := &mockUserRepoForHandler{} - authSvc := services.NewAuthService(cfg, userRepo) - - trackRepo := &mockTrackRepoForHandler{} - dailyRepo := &mockDailyRepo{cache: make(map[string]*models.DailyPlaylistDoc)} - dailySvc := services.NewDailyPlaylistService(dailyRepo, trackRepo) - handler := NewDailyPlaylistHandler(dailySvc, authSvc) - - r := chi.NewRouter() - handler.Routes(r) - - // 1. Get available playlists - req := httptest.NewRequest("GET", "/playlists/available", nil) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for /playlists/available, got %d", rec.Code) - } - - // 2. Get daily playlist - req = httptest.NewRequest("GET", "/daily-playlist/trending", nil) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for /daily-playlist/trending, got %d", rec.Code) - } -} - -func TestAccessControlHandler(t *testing.T) { - cfg := &config.Config{ - GuestPassword: "admin_guest_pwd", - OwnerIDs: []int64{999}, - } - userRepo := &mockUserRepoForHandler{} - authSvc := services.NewAuthService(cfg, userRepo) - accessRepo := newMockAccessRepo() - accessSvc := services.NewAccessControlService(accessRepo) - - accessRepo.users[555] = &models.User{ID: 555, Username: "targetuser", Status: "active"} - - handler := NewAccessControlHandler(accessSvc, authSvc, cfg) - r := chi.NewRouter() - handler.Routes(r) - - // Admin token - adminUser := &models.User{ID: 999, Username: "admin"} - adminToken, _ := authSvc.CreateToken(adminUser) - - // 1. Get policy - req := httptest.NewRequest("GET", "/admin/access/policy", nil) - req.Header.Set("Authorization", "Bearer "+adminToken) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for GET /admin/access/policy, got %d", rec.Code) - } - - // 2. Patch policy to invite - patchBody := `{"registration_mode": "invite"}` - req = httptest.NewRequest("PATCH", "/admin/access/policy", bytes.NewBufferString(patchBody)) - req.Header.Set("Authorization", "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for PATCH /admin/access/policy, got %d", rec.Code) - } - - // 3. Create invite - invBody := `{"max_uses": 5, "ttl_days": 10, "note": "VIP invite"}` - req = httptest.NewRequest("POST", "/admin/access/invites", bytes.NewBufferString(invBody)) - req.Header.Set("Authorization", "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for POST /admin/access/invites, got %d", rec.Code) - } - - var invResp struct { - OK bool `json:"ok"` - Invite models.InviteCode `json:"invite"` - } - _ = json.NewDecoder(rec.Body).Decode(&invResp) - if invResp.Invite.Code == "" { - t.Fatalf("expected generated invite code, got empty") - } - - // 4. Lock user - lockBody := `{"reason": "Rule violation", "revoke_sessions": true}` - req = httptest.NewRequest("POST", "/admin/access/users/555/lock", bytes.NewBufferString(lockBody)) - req.Header.Set("Authorization", "Bearer "+adminToken) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for locking user, got %d", rec.Code) - } - if accessRepo.users[555].Status != "locked" { - t.Fatalf("expected user status locked, got %s", accessRepo.users[555].Status) - } - - // 5. Reverify - req = httptest.NewRequest("POST", "/admin/access/reverify", bytes.NewBufferString(`{"user_id": 555}`)) - req.Header.Set("Authorization", "Bearer "+adminToken) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for /admin/access/reverify, got %d", rec.Code) - } -} - -func TestShareHandler(t *testing.T) { - cfg := &config.Config{} - trackRepo := &mockTrackRepoForHandler{} - favRepo := &mockFavRepoForHandler{ - playlists: map[string]*models.UserPlaylist{ - "pl_share": { - ID: "pl_share", - Name: "Chill Vibes", - }, - }, - tracks: map[string][]string{ - "pl_share": {"trk_share"}, - }, - } - - handler := NewShareHandler(trackRepo, favRepo, cfg) - r := chi.NewRouter() - handler.Routes(r) - - // 1. Share track HTML preview - req := httptest.NewRequest("GET", "/share/trk_share", nil) - req.Header.Set("Accept", "text/html") - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for HTML track share, got %d", rec.Code) - } - body := rec.Body.String() - if !bytes.Contains([]byte(body), []byte("og:title")) { - t.Fatalf("expected OpenGraph meta tags in HTML preview") - } - - // 2. Share track JSON metadata - req = httptest.NewRequest("GET", "/share/trk_share?format=json", nil) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for JSON track share, got %d", rec.Code) - } - - // 3. Share playlist HTML preview - req = httptest.NewRequest("GET", "/share/playlists/pl_share", nil) - rec = httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for playlist share, got %d", rec.Code) - } -} diff --git a/internal/api/handlers/media_extra.go b/internal/api/handlers/media_extra.go index 6b69d5d..a1b4c71 100644 --- a/internal/api/handlers/media_extra.go +++ b/internal/api/handlers/media_extra.go @@ -1,26 +1,87 @@ package handlers import ( + "context" "fmt" + "html" + "io" "net/http" "os" "path/filepath" + "regexp" "strings" + "time" "github.com/go-chi/chi/v5" "streamgo/internal/api" + "streamgo/internal/repository" "streamgo/internal/services" ) +var ( + preRegex = regexp.MustCompile(`(?s)]*>(.*?)`) + articleRegex = regexp.MustCompile(`(?s)]*id="_tl_editor"[^>]*>(.*?)`) + tagRegex = regexp.MustCompile(`<[^>]+>`) +) + +func isURL(s string) bool { + trimmed := strings.TrimSpace(s) + return strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") +} + +func fetchTelegraphLyrics(ctx context.Context, u string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + + client := &http.Client{Timeout: 6 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + htmlStr := string(body) + if m := preRegex.FindStringSubmatch(htmlStr); len(m) > 1 { + decoded := html.UnescapeString(m[1]) + return strings.TrimSpace(decoded), nil + } + + if m := articleRegex.FindStringSubmatch(htmlStr); len(m) > 1 { + content := m[1] + content = strings.ReplaceAll(content, "
", "\n") + content = strings.ReplaceAll(content, "
", "\n") + content = strings.ReplaceAll(content, "

", "\n") + content = tagRegex.ReplaceAllString(content, "") + decoded := html.UnescapeString(content) + return strings.TrimSpace(decoded), nil + } + + return "", fmt.Errorf("no lyrics content found in telegraph page") +} + // MediaExtraHandler handles static cover images and track lyrics. type MediaExtraHandler struct { - trackSvc *services.TrackService + trackSvc *services.TrackService + lyricsSvc *services.LyricsEnrichmentService + trackRepo repository.TrackRepository } // NewMediaExtraHandler creates a new MediaExtraHandler. -func NewMediaExtraHandler(trackSvc *services.TrackService) *MediaExtraHandler { - return &MediaExtraHandler{trackSvc: trackSvc} +func NewMediaExtraHandler(trackSvc *services.TrackService, lyricsSvc *services.LyricsEnrichmentService, trackRepo repository.TrackRepository) *MediaExtraHandler { + return &MediaExtraHandler{ + trackSvc: trackSvc, + lyricsSvc: lyricsSvc, + trackRepo: trackRepo, + } } // Routes mounts cover and lyrics endpoints on Chi router. @@ -97,7 +158,7 @@ func (h *MediaExtraHandler) GetCoverRedirect(w http.ResponseWriter, r *http.Requ http.Redirect(w, r, coverURL, http.StatusFound) } -// GetLyrics returns lyrics for a track either in plain text or JSON. +// GetLyrics returns lyrics for a track either in plain text or JSON matching Python source. func (h *MediaExtraHandler) GetLyrics(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { @@ -111,29 +172,111 @@ func (h *MediaExtraHandler) GetLyrics(w http.ResponseWriter, r *http.Request) { return } - lyrics := track.Lyrics - if lyrics == "" { - lyrics = track.Audio.Lyrics + requestedProvider := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("provider"))) + + var ( + lyricsText string + lyricsKind string = "plain" + lyricsSource string = "unknown" + telegraphURL string + ) + + // 1. Identify if track has a telegraph link + rawLyrics := strings.TrimSpace(track.Lyrics) + if rawLyrics == "" { + rawLyrics = strings.TrimSpace(track.Audio.Lyrics) + } + if isURL(rawLyrics) { + telegraphURL = rawLyrics + } + + // 2. Check if lyrics_cache has valid lyrics text + if track.LyricsCache != nil { + if txt, ok := track.LyricsCache["text"].(string); ok && strings.TrimSpace(txt) != "" && !isURL(strings.TrimSpace(txt)) { + cachedSrc, _ := track.LyricsCache["source"].(string) + // If no specific provider was requested, or the cached source matches + if requestedProvider == "" || requestedProvider == "auto" || strings.EqualFold(cachedSrc, requestedProvider) { + lyricsText = strings.TrimSpace(txt) + if k, ok := track.LyricsCache["kind"].(string); ok && k != "" { + lyricsKind = k + } + if cachedSrc != "" { + lyricsSource = cachedSrc + } + } + } + } + + // 3. If rawLyrics is NOT a URL and not empty, and we don't have cached lyrics yet + if lyricsText == "" && rawLyrics != "" && !isURL(rawLyrics) { + lyricsText = rawLyrics + } + + // 4. If we still don't have lyrics, but we have a Telegraph / Web URL, fetch & parse the page + if lyricsText == "" && telegraphURL != "" { + scraped, err := fetchTelegraphLyrics(r.Context(), telegraphURL) + if err == nil && strings.TrimSpace(scraped) != "" { + lyricsText = strings.TrimSpace(scraped) + lyricsKind = "synced" + if strings.Contains(lyricsText, "<") && strings.Contains(lyricsText, ">") { + lyricsKind = "richsync" + } + lyricsSource = "telegraph" + // Persist to MongoDB cache so future calls are instant + if h.trackRepo != nil { + _ = h.trackRepo.UpdateLyricsCache(r.Context(), id, lyricsText, lyricsKind, lyricsSource, telegraphURL) + } + } } - if lyrics == "" { + // 5. If we still don't have lyrics (or a specific provider was requested), query lyrics providers live + if (lyricsText == "" || (requestedProvider != "" && requestedProvider != "auto" && !strings.EqualFold(lyricsSource, requestedProvider))) && h.lyricsSvc != nil { + title := track.Audio.Title + artist := track.Audio.Artist + if artist == "" { + artist = track.EffectiveArtist() + } + album := track.Audio.Album + + res, err := h.lyricsSvc.FetchLyricsWithProvider(r.Context(), title, artist, album, requestedProvider) + if err == nil && res != nil && strings.TrimSpace(res.Lyrics) != "" { + lyricsText = strings.TrimSpace(res.Lyrics) + if res.Kind != "" { + lyricsKind = res.Kind + } + if res.Source != "" { + lyricsSource = res.Source + } + if h.trackRepo != nil { + _ = h.trackRepo.UpdateLyricsCache(r.Context(), id, lyricsText, lyricsKind, lyricsSource, telegraphURL) + } + } + } + + if lyricsText == "" { api.RespondError(w, http.StatusNotFound, "lyrics not found") return } - - format := r.URL.Query().Get("format") + format := strings.ToLower(r.URL.Query().Get("format")) accept := r.Header.Get("Accept") - if format == "plain" || strings.Contains(accept, "text/plain") { + if format == "plain" || (format != "json" && strings.Contains(accept, "text/plain")) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(lyrics)) + _, _ = w.Write([]byte(lyricsText)) return } - api.RespondJSON(w, http.StatusOK, map[string]any{ + resp := map[string]any{ "ok": true, "track_id": id, - "lyrics": lyrics, - }) + "lyrics": lyricsText, + "kind": lyricsKind, + "source": lyricsSource, + } + if telegraphURL != "" { + resp["telegraph_url"] = telegraphURL + } + + api.RespondJSON(w, http.StatusOK, resp) } diff --git a/internal/api/handlers/playlists_favourites_discord_test.go b/internal/api/handlers/playlists_favourites_discord_test.go deleted file mode 100644 index b1681ac..0000000 --- a/internal/api/handlers/playlists_favourites_discord_test.go +++ /dev/null @@ -1,412 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - - "streamgo/internal/config" - "streamgo/internal/models" - "streamgo/internal/services" -) - -type mockFavRepoForHandler struct { - playlists map[string]*models.UserPlaylist - tracks map[string][]string - favs map[int64][]string - artists map[int64][]string - albums map[int64][]string -} - -func (m *mockFavRepoForHandler) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { - for _, id := range m.favs[userID] { - if id == trackID { - return true, nil - } - } - m.favs[userID] = append(m.favs[userID], trackID) - return false, nil -} - -func (m *mockFavRepoForHandler) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { - ids := m.favs[userID] - for i, id := range ids { - if id == trackID { - m.favs[userID] = append(ids[:i], ids[i+1:]...) - return true, nil - } - } - return false, nil -} - -func (m *mockFavRepoForHandler) GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { - ids := m.favs[userID] - return ids, int64(len(ids)), nil, nil -} - -func (m *mockFavRepoForHandler) GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { - ids := m.favs[userID] - return ids, int64(len(ids)), nil, nil -} - -func (m *mockFavRepoForHandler) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { - for _, id := range m.artists[userID] { - if id == artistID { - return true, nil - } - } - m.artists[userID] = append(m.artists[userID], artistID) - return false, nil -} - -func (m *mockFavRepoForHandler) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { - ids := m.artists[userID] - for i, id := range ids { - if id == artistID { - m.artists[userID] = append(ids[:i], ids[i+1:]...) - return true, nil - } - } - return false, nil -} - -func (m *mockFavRepoForHandler) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { - return m.artists[userID], nil -} - -func (m *mockFavRepoForHandler) CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error { - m.playlists[p.ID] = p - return nil -} - -func (m *mockFavRepoForHandler) GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) { - var res []*models.UserPlaylist - for _, p := range m.playlists { - if p.UserID == userID { - res = append(res, p) - } - } - return res, nil -} - -func (m *mockFavRepoForHandler) GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) { - p, ok := m.playlists[playlistID] - if !ok || p.UserID != userID { - return nil, nil - } - return p, nil -} - -func (m *mockFavRepoForHandler) GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { - return m.playlists[playlistID], nil -} - -func (m *mockFavRepoForHandler) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) { - p := m.playlists[playlistID] - if p == nil || p.UserID != userID { - return nil, nil - } - p.Name = name - return p, nil -} - -func (m *mockFavRepoForHandler) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { - delete(m.playlists, playlistID) - return nil -} - -func (m *mockFavRepoForHandler) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) { - m.tracks[playlistID] = append(m.tracks[playlistID], trackIDs...) - return len(trackIDs), nil -} - -func (m *mockFavRepoForHandler) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { - ids := m.tracks[playlistID] - for i, id := range ids { - if id == trackID { - m.tracks[playlistID] = append(ids[:i], ids[i+1:]...) - return nil - } - } - return nil -} - -func (m *mockFavRepoForHandler) GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) { - ids := m.tracks[playlistID] - return ids, int64(len(ids)), nil -} - -func (m *mockFavRepoForHandler) GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) { - ids := m.tracks[playlistID] - if len(ids) > limit { - return ids[:limit], nil - } - return ids, nil -} - -func (m *mockFavRepoForHandler) SaveAlbum(ctx context.Context, userID int64, albumID string) (bool, error) { - for _, id := range m.albums[userID] { - if id == albumID { - return true, nil - } - } - m.albums[userID] = append(m.albums[userID], albumID) - return false, nil -} - -func (m *mockFavRepoForHandler) GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) ([]string, []float64, int64, error) { - ids := m.albums[userID] - ts := make([]float64, len(ids)) - return ids, ts, int64(len(ids)), nil -} - -func (m *mockFavRepoForHandler) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { - return len(events), nil -} - -func (m *mockFavRepoForHandler) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) { - return []string{}, nil -} - -func (m *mockFavRepoForHandler) GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) { - return []string{}, 0, nil -} - -type mockTrackRepoForHandler struct{} - -func (m *mockTrackRepoForHandler) GetByID(ctx context.Context, id string) (*models.Track, error) { - return &models.Track{ID: id, Audio: models.AudioMeta{Title: "Test", Artist: "Artist"}}, nil -} - -func (m *mockTrackRepoForHandler) GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) { - var res []*models.Track - for _, id := range ids { - res = append(res, &models.Track{ID: id, Audio: models.AudioMeta{Title: "Test", Artist: "Artist"}}) - } - return res, nil -} - -func (m *mockTrackRepoForHandler) List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) { - return nil, 0, nil -} - -func (m *mockTrackRepoForHandler) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) { - return nil, nil -} - -func (m *mockTrackRepoForHandler) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) { - return nil, nil -} - -func (m *mockTrackRepoForHandler) GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) { - return []*models.TopicItem{ - { - Name: "EDM", - TopicName: "EDM", - Count: 15, - TracksCount: 15, - CoverURL: "https://example.com/edm.jpg", - Endpoint: "/topics/EDM/tracks", - Thumbnails: []string{"https://example.com/edm.jpg"}, - }, - }, nil -} - -func (m *mockTrackRepoForHandler) GetChannelIDs(ctx context.Context) ([]int64, error) { - return nil, nil -} - -func (m *mockTrackRepoForHandler) IncrementPlayCount(ctx context.Context, id string) error { - return nil -} - -func (m *mockTrackRepoForHandler) UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error { - return nil -} - -type mockUserRepoForHandler struct{} - -func (m *mockUserRepoForHandler) UpsertUser(ctx context.Context, user *models.User) (*models.User, error) { - return user, nil -} -func (m *mockUserRepoForHandler) GetUserByID(ctx context.Context, id int64) (*models.User, error) { - return &models.User{ID: id, Username: "test"}, nil -} -func (m *mockUserRepoForHandler) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { - return &models.User{ID: 100, Username: username}, nil -} -func (m *mockUserRepoForHandler) UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error { - return nil -} -func (m *mockUserRepoForHandler) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { - return &models.UserIntegrations{}, nil -} -func (m *mockUserRepoForHandler) SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error { - return nil -} -func (m *mockUserRepoForHandler) GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) { - return nil, nil -} -func (m *mockUserRepoForHandler) DeleteRegistrationOTP(ctx context.Context, userID int64) error { - return nil -} -func (m *mockUserRepoForHandler) SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error { - return nil -} -func (m *mockUserRepoForHandler) GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) { - return nil, nil -} -func (m *mockUserRepoForHandler) GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) { - return nil, nil -} -func (m *mockUserRepoForHandler) SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error { - return nil -} -func (m *mockUserRepoForHandler) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { - return nil -} -func (m *mockUserRepoForHandler) SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error { - return nil -} -func (m *mockUserRepoForHandler) GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) { - return nil, nil -} - -func TestPlaylistsAndFavouritesHandlers(t *testing.T) { - cfg := &config.Config{SecretKey: "secret"} - authSvc := services.NewAuthService(cfg, &mockUserRepoForHandler{}) - token, _ := authSvc.CreateToken(&models.User{ID: 12345, Username: "testuser"}) - - favRepo := &mockFavRepoForHandler{ - playlists: make(map[string]*models.UserPlaylist), - tracks: make(map[string][]string), - favs: make(map[int64][]string), - artists: make(map[int64][]string), - albums: make(map[int64][]string), - } - trackRepo := &mockTrackRepoForHandler{} - favSvc := services.NewFavouritePlaylistService(favRepo, trackRepo, nil) - discordSvc := services.NewDiscordService() - - playlistH := NewPlaylistHandler(favSvc, authSvc) - favH := NewFavouriteHandler(favSvc, authSvc) - discordH := NewDiscordHandler(discordSvc) - - r := chi.NewRouter() - playlistH.Routes(r) - favH.Routes(r) - discordH.Routes(r) - - // 1. Create playlist - body := `{"name": "Vibes"}` - req := httptest.NewRequest(http.MethodPost, "/me/playlists", bytes.NewBufferString(body)) - req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var pResp models.PlaylistItem - _ = json.Unmarshal(w.Body.Bytes(), &pResp) - if pResp.Name != "Vibes" { - t.Errorf("expected playlist name Vibes, got %s", pResp.Name) - } - - // 2. Add Track to playlist - addBody := `{"track_id": "trk_99"}` - req = httptest.NewRequest(http.MethodPost, "/me/playlists/"+pResp.PlaylistID+"/tracks", bytes.NewBufferString(addBody)) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - // 3. Add favourite track - favBody := `{"track_id": "trk_fav_1"}` - req = httptest.NewRequest(http.MethodPost, "/me/favourites", bytes.NewBufferString(favBody)) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // 4. Get favourite track IDs - req = httptest.NewRequest(http.MethodGet, "/me/favourites/ids", nil) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - // 5. Discord remote auth status for missing session -> 404 - req = httptest.NewRequest(http.MethodGet, "/api/discord/remote-auth/status/non-existent", nil) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 for non-existent discord session, got %d", w.Code) - } -} - -func TestTopicsAndAuthEndpoints(t *testing.T) { - cfg := &config.Config{SecretKey: "secret"} - authSvc := services.NewAuthService(cfg, &mockUserRepoForHandler{}) - trackRepo := &mockTrackRepoForHandler{} - trackSvc := services.NewTrackService(trackRepo) - topicH := NewTopicHandler(trackSvc) - authH := NewAuthHandler(cfg, authSvc) - - r := chi.NewRouter() - topicH.Routes(r) - authH.Routes(r) - - // 1. GET /topics - req := httptest.NewRequest(http.MethodGet, "/topics", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var topicsResp models.TopicsResponse - if err := json.Unmarshal(w.Body.Bytes(), &topicsResp); err != nil { - t.Fatalf("failed to unmarshal topics: %v", err) - } - if !topicsResp.OK || topicsResp.Total != 1 || len(topicsResp.Items) != 1 || len(topicsResp.Topics) != 1 { - t.Fatalf("unexpected topics response: %+v", topicsResp) - } - if topicsResp.Topics[0] != "EDM" || topicsResp.Items[0].Name != "EDM" || topicsResp.Items[0].Count != 15 { - t.Fatalf("unexpected topic item data: %+v", topicsResp.Items[0]) - } - - // 2. POST /auth/fcm-token with valid auth token - token, _ := authSvc.CreateToken(&models.User{ID: 100, Username: "test"}) - req = httptest.NewRequest(http.MethodPost, "/auth/fcm-token", bytes.NewBufferString(`{"fcm_token": "token_xyz"}`)) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // 3. POST /auth/telegram/bot-session - req = httptest.NewRequest(http.MethodPost, "/auth/telegram/bot-session", bytes.NewBufferString(`{"invite_code": "INV123"}`)) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - var sessResp map[string]any - _ = json.Unmarshal(w.Body.Bytes(), &sessResp) - if sessResp["ok"] != true || sessResp["session_id"] == nil { - t.Fatalf("unexpected bot session response: %+v", sessResp) - } -} diff --git a/internal/api/handlers/sources_test.go b/internal/api/handlers/sources_test.go deleted file mode 100644 index 93cc29b..0000000 --- a/internal/api/handlers/sources_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - - "streamgo/internal/config" - "streamgo/internal/services" -) - -func setupSourcesTestRouter() (*chi.Mux, *services.AccessFilter) { - cfg := &config.Config{ - ChannelID: -1003961478268, - DumpChannelID: -1004300252384, - SecretKey: "test-secret", - } - - filter := services.NewAccessFilter(cfg, nil) - handler := NewSourcesHandler(cfg, filter, nil) - - r := chi.NewRouter() - handler.Routes(r) - return r, filter -} - -func TestSourcesOverviewAndModeEndpoints(t *testing.T) { - router, _ := setupSourcesTestRouter() - - // 1. GET /admin/sources without auth -> 401 - req := httptest.NewRequest(http.MethodGet, "/admin/sources/", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 unauthorized, got %d", w.Code) - } - - // 2. GET /admin/sources with X-Secret-Key -> 200 - req = httptest.NewRequest(http.MethodGet, "/admin/sources/", nil) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) - } - - var overviewResp map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &overviewResp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if overviewResp["ok"] != true { - t.Errorf("expected ok: true") - } - - // 3. GET /admin/sources/mode - req = httptest.NewRequest(http.MethodGet, "/admin/sources/mode", nil) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - // 4. POST /admin/sources/mode to "anyone" - setModeBody := `{"mode": "anyone"}` - req = httptest.NewRequest(http.MethodPost, "/admin/sources/mode", bytes.NewBufferString(setModeBody)) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) - } - - var modeResp map[string]any - _ = json.Unmarshal(w.Body.Bytes(), &modeResp) - if modeResp["filter_mode_name"] != "anyone" { - t.Errorf("expected filter_mode_name anyone, got %v", modeResp["filter_mode_name"]) - } -} - -func TestSourcesAllowedAndBannedEndpoints(t *testing.T) { - router, _ := setupSourcesTestRouter() - - // 1. POST /admin/sources/allowed - allowedBody := `{"source_id": -100123456789, "source_type": "channel", "name": "Test Channel"}` - req := httptest.NewRequest(http.MethodPost, "/admin/sources/allowed", bytes.NewBufferString(allowedBody)) - req.Header.Set("X-Secret-Key", "test-secret") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) - } - - // 2. GET /admin/sources/allowed - req = httptest.NewRequest(http.MethodGet, "/admin/sources/allowed", nil) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - // 3. DELETE /admin/sources/allowed/-100123456789 - req = httptest.NewRequest(http.MethodDelete, "/admin/sources/allowed/-100123456789", nil) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - // 4. POST /admin/sources/banned - banBody := `{"source_id": 987654321, "source_type": "user", "name": "Bad Actor", "reason": "Spamming"}` - req = httptest.NewRequest(http.MethodPost, "/admin/sources/banned", bytes.NewBufferString(banBody)) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) - } - - // 5. GET /admin/sources/banned - req = httptest.NewRequest(http.MethodGet, "/admin/sources/banned", nil) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - // 6. DELETE /admin/sources/banned/987654321 - req = httptest.NewRequest(http.MethodDelete, "/admin/sources/banned/987654321", nil) - req.Header.Set("X-Secret-Key", "test-secret") - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } -} diff --git a/internal/repository/track_repo.go b/internal/repository/track_repo.go index 001e9e5..97cb31e 100644 --- a/internal/repository/track_repo.go +++ b/internal/repository/track_repo.go @@ -28,6 +28,7 @@ type TrackRepository interface { GetChannelIDs(ctx context.Context) ([]int64, error) IncrementPlayCount(ctx context.Context, id string) error UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error + UpdateLyricsCache(ctx context.Context, id string, text, kind, source string, telegraphURL string) error } @@ -132,12 +133,66 @@ func (r *mongoTrackRepository) List( return nil, 0, fmt.Errorf("failed to count tracks: %w", err) } - // Default sort by updated_at descending - sortDoc := bson.D{{Key: "updated_at", Value: -1}} - if sortField == "play_count" { - sortDoc = bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}} - } else if sortField == "created_at" { - sortDoc = bson.D{{Key: "created_at", Value: -1}} + // Sort specification matching Python source (Api/services/track_service.py line 167) + var sortDoc bson.D + sField := strings.ToLower(strings.TrimSpace(sortField)) + switch sField { + case "play_count", "plays", "popular": + sortDoc = bson.D{ + {Key: "play_count", Value: -1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "oldest": + sortDoc = bson.D{ + {Key: "created_at", Value: 1}, + {Key: "source_message_id", Value: 1}, + {Key: "_id", Value: 1}, + } + case "title", "title_asc", "name": + sortDoc = bson.D{ + {Key: "audio.title", Value: 1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "title_desc": + sortDoc = bson.D{ + {Key: "audio.title", Value: -1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "artist", "artist_asc": + sortDoc = bson.D{ + {Key: "audio.artist", Value: 1}, + {Key: "audio.title", Value: 1}, + {Key: "_id", Value: -1}, + } + case "updated", "updated_at": + sortDoc = bson.D{ + {Key: "updated_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "recent", "recently_added", "newest", "latest", "created_at": + sortDoc = bson.D{ + {Key: "created_at", Value: -1}, + {Key: "source_message_id", Value: -1}, + {Key: "_id", Value: -1}, + } + default: + // Default matches Python: [("source_message_id", -1)] if channel_id is not None else [("created_at", -1), ("source_message_id", -1), ("_id", -1)] + if channelID != 0 { + sortDoc = bson.D{ + {Key: "source_message_id", Value: -1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + } else { + sortDoc = bson.D{ + {Key: "created_at", Value: -1}, + {Key: "source_message_id", Value: -1}, + {Key: "_id", Value: -1}, + } + } } skip := int64((page - 1) * perPage) @@ -394,3 +449,19 @@ func (r *mongoTrackRepository) UpdateWorkerFileID(ctx context.Context, trackID, _, err := r.col.UpdateOne(ctx, filter, update) return err } + +func (r *mongoTrackRepository) UpdateLyricsCache(ctx context.Context, id string, text, kind, source string, telegraphURL string) error { + filter := bson.M{"_id": id} + set := bson.M{ + "lyrics_cache.text": text, + "lyrics_cache.kind": kind, + "lyrics_cache.source": source, + "lyrics_cache.updated_at": float64(time.Now().Unix()), + "updated_at": float64(time.Now().Unix()), + } + if telegraphURL != "" { + set["lyrics"] = telegraphURL + } + _, err := r.col.UpdateOne(ctx, filter, bson.M{"$set": set}) + return err +} diff --git a/internal/server/router.go b/internal/server/router.go index dc43935..9d2cc18 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -114,6 +114,7 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * histSvc := services.NewHistoryService(historyRepo, trackRepo) dailySvc := services.NewDailyPlaylistService(dailyRepo, trackRepo) accessSvc := services.NewAccessControlService(accessRepo) + lyricsSvc := services.NewLyricsEnrichmentService(cfg) // Handlers trackHandler := handlers.NewTrackHandler(trackSvc) @@ -124,7 +125,7 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * favHandler := handlers.NewFavouriteHandler(favPlaylistSvc, authSvc) playlistHandler := handlers.NewPlaylistHandler(favPlaylistSvc, authSvc) authHandler := handlers.NewAuthHandler(cfg, authSvc) - mediaExtraHandler := handlers.NewMediaExtraHandler(trackSvc) + mediaExtraHandler := handlers.NewMediaExtraHandler(trackSvc, lyricsSvc, trackRepo) sourcesHandler := handlers.NewSourcesHandler(cfg, filter, authSvc) discordHandler := handlers.NewDiscordHandler(discordSvc) historyHandler := handlers.NewHistoryHandler(histSvc, authSvc) diff --git a/internal/services/favourite_playlist_test.go b/internal/services/favourite_playlist_test.go index 4524600..38170f6 100644 --- a/internal/services/favourite_playlist_test.go +++ b/internal/services/favourite_playlist_test.go @@ -231,6 +231,10 @@ func (m *mockTrackRepo) UpdateWorkerFileID(ctx context.Context, trackID, workerI return nil } +func (m *mockTrackRepo) UpdateLyricsCache(ctx context.Context, id string, text, kind, source string, telegraphURL string) error { + return nil +} + func TestFavouritePlaylistService(t *testing.T) { ctx := context.Background() favRepo := newMockFavRepo() diff --git a/internal/services/lyrics_enrichment_service.go b/internal/services/lyrics_enrichment_service.go index 3afb07b..cae1403 100644 --- a/internal/services/lyrics_enrichment_service.go +++ b/internal/services/lyrics_enrichment_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/base64" "encoding/json" "fmt" "math" @@ -55,8 +56,13 @@ func NewLyricsEnrichmentService(cfg *config.Config) *LyricsEnrichmentService { } } -// FetchLyrics searches for lyrics and romanized/localized titles. +// FetchLyrics searches for lyrics using default provider ordering. func (s *LyricsEnrichmentService) FetchLyrics(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + return s.FetchLyricsWithProvider(ctx, title, artist, album, "auto") +} + +// FetchLyricsWithProvider searches for lyrics respecting user/frontend provider preference. +func (s *LyricsEnrichmentService) FetchLyricsWithProvider(ctx context.Context, title, artist, album, provider string) (*LyricsResult, error) { if s.cfg != nil && !s.cfg.Lyrics { return nil, nil // Lyrics fetching disabled } @@ -69,25 +75,78 @@ func (s *LyricsEnrichmentService) FetchLyrics(ctx context.Context, title, artist return nil, fmt.Errorf("title cannot be empty") } - // 1. Try Musixmatch if enabled (defaults to true if config flag set) - if s.cfg == nil || s.cfg.Musixmatch { + p := strings.ToLower(strings.TrimSpace(provider)) + + // Helper runners + runMusixmatch := func() (*LyricsResult, error) { res, err := s.queryMusixmatch(ctx, cleanTitle, cleanArtist, cleanAlbum) if err == nil && res != nil && (res.Lyrics != "" || len(res.Titles) > 0) { return res, nil } - if err != nil { - logLyrics.Debugf("Musixmatch query failed for %s - %s: %v", cleanArtist, cleanTitle, err) - } + return nil, err } - // 2. Try LRCLIB if enabled or as fallback - if s.cfg == nil || s.cfg.LRCLIB { + runLRCLIB := func() (*LyricsResult, error) { res, err := s.queryLRCLIB(ctx, cleanTitle, cleanArtist, cleanAlbum) - if err == nil && res != nil { + if err == nil && res != nil && res.Lyrics != "" { + return res, nil + } + return nil, err + } + + runKuGou := func() (*LyricsResult, error) { + res, err := s.queryKuGou(ctx, cleanTitle, cleanArtist, 0) + if err == nil && res != nil && res.Lyrics != "" { + return res, nil + } + return nil, err + } + + switch p { + case "musixmatch": + if res, err := runMusixmatch(); err == nil { + return res, nil + } + if res, err := runLRCLIB(); err == nil { + return res, nil + } + if res, err := runKuGou(); err == nil { + return res, nil + } + case "lrclib": + if res, err := runLRCLIB(); err == nil { + return res, nil + } + if res, err := runMusixmatch(); err == nil { + return res, nil + } + if res, err := runKuGou(); err == nil { + return res, nil + } + case "kugou": + if res, err := runKuGou(); err == nil { + return res, nil + } + if res, err := runMusixmatch(); err == nil { + return res, nil + } + if res, err := runLRCLIB(); err == nil { return res, nil } - if err != nil { - logLyrics.Debugf("LRCLIB query failed for %s - %s: %v", cleanArtist, cleanTitle, err) + default: + // "auto" or unspecified: Musixmatch -> LRCLIB -> KuGou + if s.cfg == nil || s.cfg.Musixmatch { + if res, err := runMusixmatch(); err == nil { + return res, nil + } + } + if s.cfg == nil || s.cfg.LRCLIB { + if res, err := runLRCLIB(); err == nil { + return res, nil + } + } + if res, err := runKuGou(); err == nil { + return res, nil } } @@ -420,6 +479,125 @@ func (s *LyricsEnrichmentService) queryLRCLIB(ctx context.Context, title, artist }, nil } +func (s *LyricsEnrichmentService) queryKuGou(ctx context.Context, title, artist string, durationSec int) (*LyricsResult, error) { + query := fmt.Sprintf("%s - %s", strings.TrimSpace(title), strings.TrimSpace(artist)) + + // 1. Search song + searchURL := fmt.Sprintf("https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=6&showtype=0&keyword=%s", url.QueryEscape(query)) + req1, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) + if err != nil { + return nil, err + } + req1.Header.Set("User-Agent", "piTube/1.0") + + resp1, err := s.httpClient.Do(req1) + if err != nil { + return nil, err + } + defer resp1.Body.Close() + + var searchRes struct { + Data struct { + Info []struct { + Hash string `json:"hash"` + Duration float64 `json:"duration"` + } `json:"info"` + } `json:"data"` + } + if err := json.NewDecoder(resp1.Body).Decode(&searchRes); err != nil { + return nil, err + } + + if len(searchRes.Data.Info) == 0 { + return nil, fmt.Errorf("no songs found on kugou") + } + + targetHash := "" + for _, song := range searchRes.Data.Info { + if durationSec > 0 && math.Abs(song.Duration-float64(durationSec)) > 8 { + continue + } + if song.Hash != "" { + targetHash = song.Hash + break + } + } + if targetHash == "" { + targetHash = searchRes.Data.Info[0].Hash + } + if targetHash == "" { + return nil, fmt.Errorf("no valid hash found on kugou") + } + + // 2. Search lyrics candidate + candURL := fmt.Sprintf("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=%s", targetHash) + req2, err := http.NewRequestWithContext(ctx, http.MethodGet, candURL, nil) + if err != nil { + return nil, err + } + req2.Header.Set("User-Agent", "piTube/1.0") + + resp2, err := s.httpClient.Do(req2) + if err != nil { + return nil, err + } + defer resp2.Body.Close() + + var candRes struct { + Candidates []struct { + ID string `json:"id"` + AccessKey string `json:"accesskey"` + } `json:"candidates"` + } + if err := json.NewDecoder(resp2.Body).Decode(&candRes); err != nil { + return nil, err + } + if len(candRes.Candidates) == 0 { + return nil, fmt.Errorf("no lyrics candidates on kugou") + } + + candID := candRes.Candidates[0].ID + accessKey := candRes.Candidates[0].AccessKey + + // 3. Download lyrics + downURL := fmt.Sprintf("https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=%s&accesskey=%s", candID, accessKey) + req3, err := http.NewRequestWithContext(ctx, http.MethodGet, downURL, nil) + if err != nil { + return nil, err + } + req3.Header.Set("User-Agent", "piTube/1.0") + + resp3, err := s.httpClient.Do(req3) + if err != nil { + return nil, err + } + defer resp3.Body.Close() + + var downRes struct { + Content string `json:"content"` + } + if err := json.NewDecoder(resp3.Body).Decode(&downRes); err != nil { + return nil, err + } + if downRes.Content == "" { + return nil, fmt.Errorf("empty kugou content") + } + + decoded, err := base64.StdEncoding.DecodeString(downRes.Content) + if err != nil { + return nil, err + } + + lrcText := string(decoded) + return &LyricsResult{ + Lyrics: lrcText, + SyncedLyrics: lrcText, + Kind: "synced", + Source: "kugou", + Titles: map[string]any{"original": title}, + }, nil +} + func formatLRCTimestamp(totalSeconds float64) string { if totalSeconds < 0 { totalSeconds = 0 From 249b48cc3ec6a54e16198bf431e4d521c8d6ecb3 Mon Sep 17 00:00:00 2001 From: Kousik Date: Sat, 19 Sep 2026 18:55:10 +0530 Subject: [PATCH 10/39] topics --- internal/api/handlers/topics.go | 3 +- internal/config/config.go | 2 ++ internal/repository/track_repo.go | 17 +++++++---- internal/services/favourite_playlist_test.go | 2 +- internal/services/track_service.go | 4 +-- internal/telegram/listener.go | 30 ++++++++++++++++++++ 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/internal/api/handlers/topics.go b/internal/api/handlers/topics.go index 988247b..a1fe7dd 100644 --- a/internal/api/handlers/topics.go +++ b/internal/api/handlers/topics.go @@ -31,8 +31,9 @@ func (h *TopicHandler) Routes(r chi.Router) { // ListTopics handles GET /topics. func (h *TopicHandler) ListTopics(w http.ResponseWriter, r *http.Request) { limit := api.ParseQueryInt(r, "limit", 100) + channelID := api.ParseQueryInt64(r, "channel_id", 0) - resp, err := h.trackService.GetTopics(r.Context(), limit) + resp, err := h.trackService.GetTopics(r.Context(), channelID, limit) if err != nil { api.RespondError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/config/config.go b/internal/config/config.go index c22247a..589f864 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,7 @@ type Config struct { MultiClients bool MultiClientTokens []string FilterMode int + ChatTopic string CollaboratorIDs []int64 Lyrics bool LRCLIB bool @@ -112,6 +113,7 @@ func Load() *Config { MultiClients: multiClients, MultiClientTokens: multiTokens, FilterMode: getEnvInt("FILTER_MODE", 0), + ChatTopic: strings.TrimSpace(getEnv("CHAT_TOPIC", "all")), CollaboratorIDs: collabList, Lyrics: getEnvBool("LYRICS", true), LRCLIB: getEnvBool("LRCLIB", false), diff --git a/internal/repository/track_repo.go b/internal/repository/track_repo.go index 97cb31e..d4617ec 100644 --- a/internal/repository/track_repo.go +++ b/internal/repository/track_repo.go @@ -24,7 +24,7 @@ type TrackRepository interface { List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) - GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) + GetTopics(ctx context.Context, channelID int64, limit int) ([]*models.TopicItem, error) GetChannelIDs(ctx context.Context) ([]int64, error) IncrementPlayCount(ctx context.Context, id string) error UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error @@ -290,16 +290,21 @@ func (r *mongoTrackRepository) Random(ctx context.Context, limit int, channelID return tracks, nil } -func (r *mongoTrackRepository) GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) { +func (r *mongoTrackRepository) GetTopics(ctx context.Context, channelID int64, limit int) ([]*models.TopicItem, error) { if limit <= 0 || limit > 500 { limit = 100 } + matchFilter := bson.M{ + "deleted": bson.M{"$ne": true}, + "topic_name": bson.M{"$exists": true, "$nin": []interface{}{"", "null", "None", nil}}, + } + if channelID != 0 { + matchFilter["source_chat_id"] = channelID + } + pipeline := mongo.Pipeline{ - bson.D{{Key: "$match", Value: bson.M{ - "deleted": bson.M{"$ne": true}, - "topic_name": bson.M{"$exists": true, "$nin": []interface{}{"", "null", "None", nil}}, - }}}, + bson.D{{Key: "$match", Value: matchFilter}}, bson.D{{Key: "$sort", Value: bson.D{ {Key: "updated_at", Value: -1}, {Key: "source_message_id", Value: -1}, diff --git a/internal/services/favourite_playlist_test.go b/internal/services/favourite_playlist_test.go index 38170f6..ee2056c 100644 --- a/internal/services/favourite_playlist_test.go +++ b/internal/services/favourite_playlist_test.go @@ -215,7 +215,7 @@ func (m *mockTrackRepo) Random(ctx context.Context, limit int, channelID int64) return nil, nil } -func (m *mockTrackRepo) GetTopics(ctx context.Context, limit int) ([]*models.TopicItem, error) { +func (m *mockTrackRepo) GetTopics(ctx context.Context, channelID int64, limit int) ([]*models.TopicItem, error) { return nil, nil } diff --git a/internal/services/track_service.go b/internal/services/track_service.go index d0910fa..3399fdd 100644 --- a/internal/services/track_service.go +++ b/internal/services/track_service.go @@ -104,8 +104,8 @@ func (s *TrackService) GetRandom(ctx context.Context, limit int, channelID int64 } // GetTopics returns aggregated topic lists with counts matching Api/schemas/topics.py. -func (s *TrackService) GetTopics(ctx context.Context, limit int) (*models.TopicsResponse, error) { - topics, err := s.repo.GetTopics(ctx, limit) +func (s *TrackService) GetTopics(ctx context.Context, channelID int64, limit int) (*models.TopicsResponse, error) { + topics, err := s.repo.GetTopics(ctx, channelID, limit) if err != nil { return nil, err } diff --git a/internal/telegram/listener.go b/internal/telegram/listener.go index 1cd9bc3..8f71de9 100644 --- a/internal/telegram/listener.go +++ b/internal/telegram/listener.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "regexp" + "strconv" "strings" "time" @@ -162,6 +163,35 @@ func (l *IngestionListener) handleMessage(ctx context.Context, msg *tg.Message) } if topicID != 0 { topicName = fmt.Sprintf("topic_%d", topicID) + if l.tracksCol != nil && l.tracksCol.Database() != nil { + ftCol := l.tracksCol.Database().Collection("forum_topics") + var ftDoc struct { + TopicName string `bson:"topic_name"` + } + err := ftCol.FindOne(ctx, bson.M{ + "$or": []bson.M{ + {"_id": fmt.Sprintf("%d:%d", chatID, topicID)}, + {"topic_id": topicID}, + }, + "topic_name": bson.M{"$exists": true, "$ne": ""}, + }).Decode(&ftDoc) + if err == nil && strings.TrimSpace(ftDoc.TopicName) != "" && !strings.HasPrefix(ftDoc.TopicName, "topic_") { + topicName = strings.TrimSpace(ftDoc.TopicName) + } + } + } + + // Filter by CHAT_TOPIC if configured + if l.cfg != nil && l.cfg.ChatTopic != "" && l.cfg.ChatTopic != "all" { + if l.cfg.ChatTopic == "0" { + if topicID != 0 { + return + } + } else if targetID, err := strconv.ParseInt(l.cfg.ChatTopic, 10, 64); err == nil { + if int64(topicID) != targetID { + return + } + } } // Source and Cache Message / Chat IDs (handle forwards properly) From 9e3767d2e23de54e03243ec88d604ba1acdc81b5 Mon Sep 17 00:00:00 2001 From: Kousik Date: Sat, 19 Sep 2026 19:10:27 +0530 Subject: [PATCH 11/39] enrichment fix --- internal/services/enrichment_service.go | 50 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/services/enrichment_service.go b/internal/services/enrichment_service.go index b2f9c34..4132ea5 100644 --- a/internal/services/enrichment_service.go +++ b/internal/services/enrichment_service.go @@ -37,6 +37,7 @@ type EnrichmentService struct { downloader MediaDownloader workQueue chan string + inFlight sync.Map wg sync.WaitGroup } @@ -70,9 +71,17 @@ func (s *EnrichmentService) SetDownloader(d MediaDownloader) { // TriggerEnrich pushes a track ID to the priority enrichment queue. func (s *EnrichmentService) TriggerEnrich(trackID string) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return + } + if _, loaded := s.inFlight.LoadOrStore(trackID, struct{}{}); loaded { + return // already queued or currently being enriched + } select { case s.workQueue <- trackID: default: + s.inFlight.Delete(trackID) logEnrich.Warnf("enrichment queue full, dropping immediate trigger for %s", trackID) } } @@ -112,9 +121,15 @@ func (s *EnrichmentService) pollLoop(ctx context.Context) { } func (s *EnrichmentService) enqueueUnenrichedTracks(ctx context.Context) { + staleThreshold := float64(time.Now().Unix() - 300) // 5 minutes filter := bson.M{ "enriched": bson.M{"$ne": true}, "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"enriching": bson.M{"$ne": true}}, + {"enrichment_started_at": bson.M{"$lt": staleThreshold}}, + {"enrichment_started_at": bson.M{"$exists": false}}, + }, } opts := options.Find().SetLimit(50).SetProjection(bson.M{"_id": 1}) @@ -129,9 +144,13 @@ func (s *EnrichmentService) enqueueUnenrichedTracks(ctx context.Context) { ID string `bson:"_id"` } if err := cursor.Decode(&doc); err == nil && doc.ID != "" { + if _, loaded := s.inFlight.LoadOrStore(doc.ID, struct{}{}); loaded { + continue // already in flight + } select { case s.workQueue <- doc.ID: default: + s.inFlight.Delete(doc.ID) return } } @@ -152,9 +171,38 @@ func (s *EnrichmentService) workerLoop(ctx context.Context, workerID int) { } func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID string) { + defer s.inFlight.Delete(trackID) + + now := float64(time.Now().Unix()) + staleThreshold := now - 300 + + // Atomically claim the track so no other worker or poller can process it concurrently + claimFilter := bson.M{ + "_id": trackID, + "enriched": bson.M{"$ne": true}, + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"enriching": bson.M{"$ne": true}}, + {"enrichment_started_at": bson.M{"$lt": staleThreshold}}, + {"enrichment_started_at": bson.M{"$exists": false}}, + }, + } + claimUpdate := bson.M{ + "$set": bson.M{ + "enriching": true, + "enrichment_started_at": now, + }, + } + var track models.Track - err := s.tracksCol.FindOne(ctx, bson.M{"_id": trackID}).Decode(&track) + err := s.tracksCol.FindOneAndUpdate( + ctx, + claimFilter, + claimUpdate, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&track) if err != nil { + // Already enriched, deleted, or claimed by another concurrent worker return } From d55088d11b69ec9bcbbf8db4eb97b0d915aebef6 Mon Sep 17 00:00:00 2001 From: Rishav Date: Tue, 22 Sep 2026 13:24:53 +0530 Subject: [PATCH 12/39] feat(stream): support ALAC browser streaming via lossless FLAC transcoding and LRU caching - Implement ALACService for on-demand ALAC-to-FLAC transcoding using FFmpeg - Support browser/client detection and format routing while keeping raw ALAC for native clients - Add thread-safe per-track locking to prevent concurrent duplicate transcode jobs - Implement background non-blocking LRU cache pruner with configurable max bytes and file limits - Add Telegram full-track download support in telegram/client.go - Integrate ALACService with StreamService, WarmTrack prewarming, and StreamHandler - Add unit tests covering ALAC detection, browser routing, and LRU cache eviction --- internal/api/handlers/stream.go | 15 +- internal/config/config.go | 4 + internal/server/router.go | 2 + internal/services/alac_service.go | 346 +++++++++++++++++++++++++ internal/services/alac_service_test.go | 240 +++++++++++++++++ internal/services/stream_service.go | 89 +++++-- internal/telegram/client.go | 29 ++- 7 files changed, 695 insertions(+), 30 deletions(-) create mode 100644 internal/services/alac_service.go create mode 100644 internal/services/alac_service_test.go diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index ceb74ca..2086ddd 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -1,10 +1,10 @@ package handlers import ( - "context" "fmt" "net/http" "net/url" + "path/filepath" "strings" "github.com/go-chi/chi/v5" @@ -72,12 +72,15 @@ func (h *StreamHandler) Download(w http.ResponseWriter, r *http.Request) { filename = fmt.Sprintf("track-%s", track.ID) } ext := track.Audio.Type + if (h.streamService.ALACService() != nil && h.streamService.ALACService().ShouldDecodeALAC(r, track)) || strings.ToLower(r.URL.Query().Get("format")) == "flac" { + ext = "flac" + } if ext == "" { ext = "mp3" } - if !strings.HasSuffix(strings.ToLower(filename), "."+strings.ToLower(ext)) { - filename = fmt.Sprintf("%s.%s", filename, ext) - } + cleanFilename := strings.TrimSuffix(filename, filepath.Ext(filename)) + filename = fmt.Sprintf("%s.%s", cleanFilename, ext) + fallback := strings.ReplaceAll(filename, `"`, `_`) encoded := url.QueryEscape(filename) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) @@ -97,7 +100,7 @@ func (h *StreamHandler) Warm(w http.ResponseWriter, r *http.Request) { return } - go h.streamService.WarmTrack(context.Background(), trackID) - api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) + h.streamService.WarmTrack(r.Context(), trackID) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "ready": true}) } diff --git a/internal/config/config.go b/internal/config/config.go index 589f864..aba9dfb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,8 @@ type Config struct { SudoUsers []int64 EnrichmentWorkers int GuestPassword string + AlacCacheMaxBytes int64 + AlacCacheMaxFiles int } // Load reads configuration from .env file (if present) and environment variables. @@ -128,6 +130,8 @@ func Load() *Config { SudoUsers: sudoUsers, EnrichmentWorkers: getEnvInt("ENRICHMENT_WORKERS", 2), GuestPassword: getEnv("GUEST_PASSWORD", ""), + AlacCacheMaxBytes: getEnvInt64("ALAC_CACHE_MAX_BYTES", 5*1024*1024*1024), + AlacCacheMaxFiles: getEnvInt("ALAC_CACHE_MAX_FILES", 200), } } diff --git a/internal/server/router.go b/internal/server/router.go index 9d2cc18..61e9a92 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -105,7 +105,9 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * // Services trackSvc := services.NewTrackService(trackRepo) + alacSvc := services.NewALACService(cfg, tg, "stream_media") streamSvc := services.NewStreamService(trackRepo, tg) + streamSvc.SetALACService(alacSvc) artistAlbumSvc := services.NewArtistAlbumService(artistAlbumRepo, trackRepo) favPlaylistSvc := services.NewFavouritePlaylistService(favRepo, trackRepo, artistAlbumRepo) authSvc := services.NewAuthService(cfg, userRepo) diff --git a/internal/services/alac_service.go b/internal/services/alac_service.go new file mode 100644 index 0000000..02cb4cd --- /dev/null +++ b/internal/services/alac_service.go @@ -0,0 +1,346 @@ +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "streamgo/internal/config" + "streamgo/internal/logger" + "streamgo/internal/models" + "streamgo/internal/telegram" +) + +var logALAC = logger.New("alac") + +// ALACService handles on-demand lossless ALAC-to-FLAC transcoding, caching, and LRU eviction. +type ALACService struct { + cfg *config.Config + tgService *telegram.Service + mediaDir string + cacheDir string + maxBytes int64 + maxFiles int + + locks sync.Map // trackID -> *sync.Mutex + pruneMu sync.Mutex +} + +// NewALACService creates and initializes a new ALACService. +func NewALACService(cfg *config.Config, tg *telegram.Service, mediaDir string) *ALACService { + if mediaDir == "" { + mediaDir = "stream_media" + } + cacheDir := filepath.Join(mediaDir, "alac_cache") + _ = os.MkdirAll(cacheDir, 0755) + + maxBytes := int64(5 * 1024 * 1024 * 1024) // 5 GB + maxFiles := 200 + + if cfg != nil { + if cfg.AlacCacheMaxBytes > 0 { + maxBytes = cfg.AlacCacheMaxBytes + } + if cfg.AlacCacheMaxFiles > 0 { + maxFiles = cfg.AlacCacheMaxFiles + } + } + + return &ALACService{ + cfg: cfg, + tgService: tg, + mediaDir: mediaDir, + cacheDir: cacheDir, + maxBytes: maxBytes, + maxFiles: maxFiles, + } +} + +// CacheDir returns the absolute or relative path to the ALAC FLAC cache directory. +func (s *ALACService) CacheDir() string { + return s.cacheDir +} + +// IsALACTrack determines if the given track contains an Apple Lossless Audio Codec stream. +func (s *ALACService) IsALACTrack(track *models.Track) bool { + if track == nil { + return false + } + + typeStr := strings.ToLower(strings.TrimSpace(track.Audio.Type)) + if strings.Contains(typeStr, "alac") { + return true + } + + genreStr := strings.ToLower(strings.TrimSpace(track.Audio.Genre)) + if strings.Contains(genreStr, "alac") { + return true + } + + mimeStr := strings.ToLower(strings.TrimSpace(track.Telegram.MimeType)) + if strings.Contains(mimeStr, "alac") { + return true + } + + nameStr := strings.ToLower(strings.TrimSpace(track.Telegram.FileName)) + if strings.Contains(nameStr, "alac") { + return true + } + + return false +} + +// ShouldDecodeALAC decides if a track should be served as a transcoded FLAC stream. +func (s *ALACService) ShouldDecodeALAC(r *http.Request, track *models.Track) bool { + if track == nil { + return false + } + + isAlac := s.IsALACTrack(track) + + // Explicit format override via query parameter + fmtParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if fmtParam == "flac" || fmtParam == "decoded" || fmtParam == "transcode" || fmtParam == "pcm" || fmtParam == "wav" { + return isAlac || !strings.Contains(strings.ToLower(track.Audio.Type), "flac") + } + if fmtParam == "raw" || fmtParam == "alac" || fmtParam == "original" || fmtParam == "source" { + return false + } + + if !isAlac { + return false + } + + // Decode parameter check + decodeParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("decode"))) + if decodeParam == "" { + decodeParam = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("transcode"))) + } + if decodeParam == "" { + decodeParam = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("alac_decode"))) + } + if decodeParam == "1" || decodeParam == "true" || decodeParam == "yes" || decodeParam == "flac" { + return true + } + if decodeParam == "0" || decodeParam == "false" || decodeParam == "no" { + return false + } + + // Check client header for native app bypass + clientHdr := strings.ToLower(strings.TrimSpace(r.Header.Get("X-StreamX-Client"))) + if clientHdr == "" { + clientHdr = strings.ToLower(strings.TrimSpace(r.Header.Get("x-streamx-client"))) + } + if strings.Contains(clientHdr, "native") || strings.Contains(clientHdr, "android-native") || strings.Contains(clientHdr, "exoplayer") { + return false + } + + // Browser detection + ua := strings.ToLower(r.Header.Get("User-Agent")) + secDest := strings.ToLower(r.Header.Get("Sec-Fetch-Dest")) + secMode := strings.ToLower(r.Header.Get("Sec-Fetch-Mode")) + secUA := strings.ToLower(r.Header.Get("Sec-Ch-Ua")) + secPlatform := strings.ToLower(r.Header.Get("Sec-Ch-Ua-Platform")) + + isBrowser := secDest != "" || secMode != "" || secUA != "" || secPlatform != "" || + strings.Contains(ua, "mozilla") || strings.Contains(ua, "chrome") || + strings.Contains(ua, "firefox") || strings.Contains(ua, "safari") || + strings.Contains(ua, "edge") || strings.Contains(ua, "edg") || + strings.Contains(ua, "opera") + + if !isBrowser { + return false + } + + // Pure Safari on Apple OS has native ALAC decoding + isAppleOS := (strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os x") || + strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod")) && + !strings.Contains(ua, "windows") && !strings.Contains(ua, "android") + + if isAppleOS && (strings.Contains(ua, "safari") || strings.Contains(ua, "applewebkit")) && + !strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg") && !strings.Contains(ua, "firefox") { + return false + } + + // Chromium, Firefox, Windows, Linux, Android browsers all lack ALAC decoder: + return true +} + +// EnsureDecodedFLAC ensures a bit-perfect FLAC transcode of the ALAC track is ready in local cache. +func (s *ALACService) EnsureDecodedFLAC(ctx context.Context, track *models.Track) (string, error) { + if track == nil { + return "", errors.New("track is nil") + } + + targetFile := filepath.Join(s.cacheDir, fmt.Sprintf("%s.flac", track.ID)) + + // 1. Check if valid FLAC already exists on disk + if info, err := os.Stat(targetFile); err == nil && info.Size() > 10240 { + s.TouchFile(targetFile) + return targetFile, nil + } + + // 2. Acquire per-track lock to prevent parallel duplicate transcoding + actualLock, _ := s.locks.LoadOrStore(track.ID, &sync.Mutex{}) + mtx := actualLock.(*sync.Mutex) + mtx.Lock() + defer mtx.Unlock() + + // 3. Double-check after lock + if info, err := os.Stat(targetFile); err == nil && info.Size() > 10240 { + s.TouchFile(targetFile) + return targetFile, nil + } + + if s.tgService == nil || !s.tgService.IsReady() { + return "", errors.New("telegram service not ready") + } + + nowMs := time.Now().UnixMilli() + pid := os.Getpid() + tempSrc := filepath.Join(s.cacheDir, fmt.Sprintf("%s.src_%d_%d.m4a", track.ID, pid, nowMs)) + tempFlac := filepath.Join(s.cacheDir, fmt.Sprintf("%s.tmp_%d_%d.flac", track.ID, pid, nowMs)) + + defer func() { + _ = os.Remove(tempSrc) + _ = os.Remove(tempFlac) + }() + + logALAC.Infof("[ALAC-Decode] Fetching source media for track %s (%s - %s)", track.ID, track.Audio.Artist, track.Audio.Title) + + // 4. Download source audio from Telegram + sf, err := os.Create(tempSrc) + if err != nil { + return "", fmt.Errorf("failed to create temp source file: %w", err) + } + + dlErr := s.tgService.DownloadTrack(ctx, track, sf) + _ = sf.Close() + if dlErr != nil { + return "", fmt.Errorf("failed to download source track from telegram: %w", dlErr) + } + + // 5. Transcode ALAC -> FLAC with -compression_level 0 for low CPU latency + logALAC.Infof("[ALAC-Decode] Transcoding %s -> %s", tempSrc, tempFlac) + cmd := exec.CommandContext(ctx, "ffmpeg", + "-hide_banner", + "-loglevel", "error", + "-y", + "-i", tempSrc, + "-vn", + "-c:a", "flac", + "-compression_level", "0", + "-f", "flac", + tempFlac, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("ffmpeg transcode failed: %v (output: %s)", err, strings.TrimSpace(string(out))) + } + + info, err := os.Stat(tempFlac) + if err != nil || info.Size() <= 10240 { + return "", fmt.Errorf("transcoded file invalid or too small: %w", err) + } + + // 6. Atomically move completed FLAC to target path + if err := os.Rename(tempFlac, targetFile); err != nil { + return "", fmt.Errorf("failed to rename temp flac to target: %w", err) + } + + s.TouchFile(targetFile) + logALAC.Infof("[ALAC-Decode] Transcode completed successfully: %s (%d bytes)", targetFile, info.Size()) + + // 7. Non-blocking asynchronous LRU cache pruning + go s.PruneCacheIfNeeded() + + return targetFile, nil +} + +// TouchFile updates the access and modification timestamp of a cached file for LRU tracking. +func (s *ALACService) TouchFile(filePath string) { + now := time.Now() + _ = os.Chtimes(filePath, now, now) +} + +type cacheEntry struct { + path string + modTime time.Time + size int64 +} + +// PruneCacheIfNeeded removes the least recently accessed files when cache limits are exceeded. +func (s *ALACService) PruneCacheIfNeeded() { + s.pruneMu.Lock() + defer s.pruneMu.Unlock() + + entries, err := os.ReadDir(s.cacheDir) + if err != nil { + return + } + + var files []cacheEntry + var totalSize int64 + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // Only manage completed .flac files; do not touch active in-flight temporary files + if !strings.HasSuffix(name, ".flac") || strings.Contains(name, ".tmp_") { + continue + } + + fullPath := filepath.Join(s.cacheDir, name) + info, err := entry.Info() + if err != nil { + continue + } + + files = append(files, cacheEntry{ + path: fullPath, + modTime: info.ModTime(), + size: info.Size(), + }) + totalSize += info.Size() + } + + if len(files) <= s.maxFiles && totalSize <= s.maxBytes { + return + } + + // Sort ascending by modification time (oldest accessed first) + sort.Slice(files, func(i, j int) bool { + return files[i].modTime.Before(files[j].modTime) + }) + + prunedCount := 0 + var prunedBytes int64 + + for _, f := range files { + if len(files)-prunedCount <= s.maxFiles && totalSize <= s.maxBytes { + break + } + + if err := os.Remove(f.path); err == nil { + prunedCount++ + prunedBytes += f.size + totalSize -= f.size + } + } + + if prunedCount > 0 { + logALAC.Infof("[ALAC-Cache-LRU] Pruned %d stale track(s), freed %.2f MB", prunedCount, float64(prunedBytes)/(1024*1024)) + } +} + diff --git a/internal/services/alac_service_test.go b/internal/services/alac_service_test.go new file mode 100644 index 0000000..eb33c6f --- /dev/null +++ b/internal/services/alac_service_test.go @@ -0,0 +1,240 @@ +package services + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "streamgo/internal/config" + "streamgo/internal/models" +) + +func TestIsALACTrack(t *testing.T) { + svc := NewALACService(nil, nil, "") + + tests := []struct { + name string + track *models.Track + expected bool + }{ + { + name: "Nil track", + track: nil, + expected: false, + }, + { + name: "Audio Type ALAC", + track: &models.Track{ + Audio: models.AudioMeta{Type: "ALAC"}, + }, + expected: true, + }, + { + name: "Audio Genre ALAC", + track: &models.Track{ + Audio: models.AudioMeta{Type: "m4a", Genre: "Apple Lossless (ALAC)"}, + }, + expected: true, + }, + { + name: "Telegram MimeType ALAC", + track: &models.Track{ + Telegram: models.TelegramMeta{MimeType: "audio/alac"}, + }, + expected: true, + }, + { + name: "Telegram FileName with alac", + track: &models.Track{ + Telegram: models.TelegramMeta{FileName: "track_alac_lossless.m4a"}, + }, + expected: true, + }, + { + name: "Standard MP3", + track: &models.Track{ + Audio: models.AudioMeta{Type: "mp3"}, + Telegram: models.TelegramMeta{MimeType: "audio/mpeg", FileName: "song.mp3"}, + }, + expected: false, + }, + { + name: "Standard FLAC", + track: &models.Track{ + Audio: models.AudioMeta{Type: "flac"}, + Telegram: models.TelegramMeta{MimeType: "audio/flac", FileName: "song.flac"}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.IsALACTrack(tt.track) + if got != tt.expected { + t.Errorf("IsALACTrack() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestShouldDecodeALAC(t *testing.T) { + svc := NewALACService(nil, nil, "") + + alacTrack := &models.Track{ + Audio: models.AudioMeta{Type: "alac"}, + Telegram: models.TelegramMeta{FileName: "song.m4a"}, + } + + mp3Track := &models.Track{ + Audio: models.AudioMeta{Type: "mp3"}, + Telegram: models.TelegramMeta{FileName: "song.mp3"}, + } + + tests := []struct { + name string + track *models.Track + url string + headers map[string]string + expected bool + }{ + { + name: "Non-ALAC track default", + track: mp3Track, + url: "/tracks/123/stream", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"}, + expected: false, + }, + { + name: "Non-ALAC track with explicit format=flac", + track: mp3Track, + url: "/tracks/123/stream?format=flac", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"}, + expected: true, + }, + { + name: "ALAC track from Chrome on Windows", + track: alacTrack, + url: "/tracks/123/stream", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, + expected: true, + }, + { + name: "ALAC track from Firefox on Linux", + track: alacTrack, + url: "/tracks/123/stream", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0"}, + expected: true, + }, + { + name: "ALAC track with format=raw override", + track: alacTrack, + url: "/tracks/123/stream?format=raw", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"}, + expected: false, + }, + { + name: "ALAC track with decode=0 override", + track: alacTrack, + url: "/tracks/123/stream?decode=0", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"}, + expected: false, + }, + { + name: "ALAC track from Native client header", + track: alacTrack, + url: "/tracks/123/stream", + headers: map[string]string{"X-StreamX-Client": "android-native", "User-Agent": "StreamX-ExoPlayer"}, + expected: false, + }, + { + name: "ALAC track with explicit format=flac from web player", + track: alacTrack, + url: "/tracks/123/stream?format=flac", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"}, + expected: true, + }, + { + name: "ALAC track from native Safari on macOS without format param", + track: alacTrack, + url: "/tracks/123/stream", + headers: map[string]string{"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + got := svc.ShouldDecodeALAC(req, tt.track) + if got != tt.expected { + t.Errorf("ShouldDecodeALAC() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestALACCacheLRUPrune(t *testing.T) { + tempDir, err := os.MkdirTemp("", "alac_lru_test_*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cfg := &config.Config{ + AlacCacheMaxBytes: 500, // Very small byte limit for test + AlacCacheMaxFiles: 3, // Max 3 files + } + + svc := NewALACService(cfg, nil, tempDir) + + // Create 5 dummy files with varying modification times and sizes (each 200 bytes) + fileNames := []string{"track1.flac", "track2.flac", "track3.flac", "track4.flac", "track5.flac"} + baseTime := time.Now().Add(-10 * time.Hour) + + for i, name := range fileNames { + p := filepath.Join(svc.CacheDir(), name) + data := make([]byte, 200) + if err := os.WriteFile(p, data, 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + // File 0 is oldest, File 4 is newest + fileTime := baseTime.Add(time.Duration(i) * time.Hour) + _ = os.Chtimes(p, fileTime, fileTime) + } + + // Before prune: 5 files, 1000 bytes total + entriesBefore, _ := os.ReadDir(svc.CacheDir()) + if len(entriesBefore) != 5 { + t.Fatalf("expected 5 files before prune, got %d", len(entriesBefore)) + } + + // Trigger synchronous prune + svc.PruneCacheIfNeeded() + + entriesAfter, _ := os.ReadDir(svc.CacheDir()) + // MaxBytes is 500 bytes and MaxFiles is 3. Each file is 200 bytes, so 2 files = 400 bytes <= 500 bytes. + if len(entriesAfter) > 3 { + t.Errorf("expected at most 3 files after prune, got %d", len(entriesAfter)) + } + + // The oldest files (track1, track2, track3) should have been pruned first. + // track5 and track4 should be preserved! + if _, err := os.Stat(filepath.Join(svc.CacheDir(), "track5.flac")); os.IsNotExist(err) { + t.Errorf("expected newest file track5.flac to be preserved, but it was deleted") + } + if _, err := os.Stat(filepath.Join(svc.CacheDir(), "track4.flac")); os.IsNotExist(err) { + t.Errorf("expected recent file track4.flac to be preserved, but it was deleted") + } + if _, err := os.Stat(filepath.Join(svc.CacheDir(), "track1.flac")); !os.IsNotExist(err) { + t.Errorf("expected oldest file track1.flac to be pruned, but it still exists") + } +} + diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index 07802a9..4a90724 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -31,9 +31,10 @@ var ErrRangeNotSatisfiable = errors.New("range not satisfiable") // StreamService coordinates audio range parsing and streaming. type StreamService struct { - repo repository.TrackRepository - tgService *telegram.Service - mediaDir string + repo repository.TrackRepository + tgService *telegram.Service + alacService *ALACService + mediaDir string } // NewStreamService creates a new StreamService. @@ -49,6 +50,16 @@ func NewStreamService(repo repository.TrackRepository, tg *telegram.Service) *St } } +// SetALACService attaches the ALACService to StreamService. +func (s *StreamService) SetALACService(alac *ALACService) { + s.alacService = alac +} + +// ALACService returns the attached ALACService. +func (s *StreamService) ALACService() *ALACService { + return s.alacService +} + // ByteRange defines start and end byte offsets. type ByteRange struct { Start int64 @@ -156,22 +167,70 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac filename = fmt.Sprintf("%s.mp3", track.ID) } - // Check if local cache file exists (e.g. decoded ALAC/FLAC) - cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) - if info, err := os.Stat(cacheFile); err == nil && info.Size() > 10240 { - f, err := os.Open(cacheFile) - if err == nil { - defer f.Close() - if w.Header().Get("Content-Disposition") == "" { - fallback := strings.ReplaceAll(filename, `"`, `_`) - encoded := url.QueryEscape(filename) - w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + serveCachedFLAC := func(filePath string) bool { + info, statErr := os.Stat(filePath) + if statErr != nil || info.Size() <= 10240 { + return false + } + f, openErr := os.Open(filePath) + if openErr != nil { + return false + } + defer f.Close() + + if s.alacService != nil { + s.alacService.TouchFile(filePath) + } + + cleanBase := track.Audio.Title + if track.Audio.Artist != "" { + cleanBase = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } + if cleanBase == "" { + cleanBase = track.Telegram.FileName + } + if cleanBase == "" { + cleanBase = track.ID + } + cleanBase = strings.TrimSuffix(cleanBase, filepath.Ext(cleanBase)) + flacFilename := fmt.Sprintf("%s.flac", cleanBase) + + fallback := strings.ReplaceAll(flacFilename, `"`, `_`) + encoded := url.QueryEscape(flacFilename) + w.Header().Set("Content-Type", "audio/flac") + w.Header().Set("Accept-Ranges", "bytes") + if w.Header().Get("Content-Disposition") == "" { + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + } + + go func(tid string) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.repo.IncrementPlayCount(timeoutCtx, tid) + }(track.ID) + + http.ServeContent(w, r, flacFilename, info.ModTime(), f) + return true + } + + // 3. If track is ALAC (or FLAC requested) and needs decoding, ensure FLAC is ready + if s.alacService != nil && s.alacService.ShouldDecodeALAC(r, track) { + flacPath, err := s.alacService.EnsureDecodedFLAC(ctx, track) + if err == nil && flacPath != "" { + if serveCachedFLAC(flacPath) { + return } - http.ServeContent(w, r, filename, info.ModTime(), f) - return + } else if err != nil { + logStream.Warnf("Failed to transcode ALAC track %s to FLAC: %v; falling back to direct stream", track.ID, err) } } + // Check if local cache file exists (e.g. decoded ALAC/FLAC) + cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) + if serveCachedFLAC(cacheFile) { + return + } + // 4. Handle Range Header rangeHeader := r.Header.Get("Range") var byteRange *ByteRange diff --git a/internal/telegram/client.go b/internal/telegram/client.go index 625519e..35cadfb 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -349,18 +349,21 @@ func (pw *partialWriter) Write(p []byte) (int, error) { return n, nil } -// DownloadPartial streams up to maxBytes of a document location into w. +// DownloadPartial streams up to maxBytes of a document location into w (or entire document if maxBytes <= 0). func (s *Service) DownloadPartial(ctx context.Context, location *tg.InputDocumentFileLocation, maxBytes int64, w io.Writer) error { worker := s.AcquireWorker() defer s.ReleaseWorker(worker) - pw := &partialWriter{ - w: w, - remain: maxBytes, + var targetWriter io.Writer = w + if maxBytes > 0 { + targetWriter = &partialWriter{ + w: w, + remain: maxBytes, + } } downloader := worker.Client.Downloader() - _, err := downloader.Download(worker.API, location).Stream(ctx, pw) + _, err := downloader.Download(worker.API, location).Stream(ctx, targetWriter) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) { return err } @@ -673,12 +676,15 @@ func (s *Service) DownloadPartialForTrack( FileReference: decoded.FileReference, } - pw := &partialWriter{ - w: w, - remain: maxBytes, + var targetWriter io.Writer = w + if maxBytes > 0 { + targetWriter = &partialWriter{ + w: w, + remain: maxBytes, + } } downloader := chosenWorker.Client.Downloader() - _, dlErr := downloader.Download(chosenWorker.API, location).Stream(ctx, pw) + _, dlErr := downloader.Download(chosenWorker.API, location).Stream(ctx, targetWriter) if dlErr != nil && !errors.Is(dlErr, io.EOF) && !errors.Is(dlErr, context.Canceled) { return dlErr } @@ -710,4 +716,9 @@ func (s *Service) DownloadPartialForTrack( return err } +// DownloadTrack downloads the entire track media into w without byte truncation. +func (s *Service) DownloadTrack(ctx context.Context, track *models.Track, w io.Writer) error { + return s.DownloadPartialForTrack(ctx, track, 0, w, nil) +} + From a043efab14d6445c1b0a37d7ed21c79b97a81502 Mon Sep 17 00:00:00 2001 From: Rishav Ghosh1 Date: Tue, 22 Sep 2026 17:09:51 +0530 Subject: [PATCH 13/39] feat(audio): implement on-the-fly ALAC to WAV streaming with instant seeking and background caching --- internal/services/alac_wav_streamer.go | 209 +++++++++++++++++++++++++ internal/services/stream_service.go | 55 +++++-- 2 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 internal/services/alac_wav_streamer.go diff --git a/internal/services/alac_wav_streamer.go b/internal/services/alac_wav_streamer.go new file mode 100644 index 0000000..715dee0 --- /dev/null +++ b/internal/services/alac_wav_streamer.go @@ -0,0 +1,209 @@ +package services + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "os/exec" + "streamgo/internal/models" +) + +type ALACWAVStreamer struct { + track *models.Track + totalSize int64 + offset int64 + port string + + cmd *exec.Cmd + stdout io.ReadCloser + cancel context.CancelFunc + + header []byte +} + +// NewALACWAVStreamer creates a new ReadSeekCloser that transcodes an ALAC track to WAV on the fly. +func NewALACWAVStreamer(track *models.Track, port string) *ALACWAVStreamer { + durationSec := track.Audio.DurationSec + if durationSec <= 0 { + durationSec = 300 // fallback 5 mins + } + + dataSize := int64(durationSec) * 176400 // 44100 Hz * 2 channels * 2 bytes (16-bit) + totalSize := 44 + dataSize + + header := make([]byte, 44) + copy(header[0:4], []byte("RIFF")) + binary.LittleEndian.PutUint32(header[4:8], uint32(36+dataSize)) + copy(header[8:12], []byte("WAVE")) + copy(header[12:16], []byte("fmt ")) + binary.LittleEndian.PutUint32(header[16:20], 16) + binary.LittleEndian.PutUint16(header[20:22], 1) + binary.LittleEndian.PutUint16(header[22:24], 2) + binary.LittleEndian.PutUint32(header[24:28], 44100) + binary.LittleEndian.PutUint32(header[28:32], 176400) + binary.LittleEndian.PutUint16(header[32:34], 4) + binary.LittleEndian.PutUint16(header[34:36], 16) + copy(header[36:40], []byte("data")) + binary.LittleEndian.PutUint32(header[40:44], uint32(dataSize)) + + return &ALACWAVStreamer{ + track: track, + totalSize: totalSize, + port: port, + header: header, + } +} + +func (s *ALACWAVStreamer) Seek(offset int64, whence int) (int64, error) { + var newOffset int64 + switch whence { + case io.SeekStart: + newOffset = offset + case io.SeekCurrent: + newOffset = s.offset + offset + case io.SeekEnd: + newOffset = s.totalSize + offset + default: + return 0, errors.New("invalid whence") + } + + if newOffset < 0 { + return 0, errors.New("negative offset") + } + + if newOffset != s.offset { + s.closeFFmpeg() + s.offset = newOffset + } + + return s.offset, nil +} + +func (s *ALACWAVStreamer) closeFFmpeg() { + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + if s.stdout != nil { + s.stdout.Close() + s.stdout = nil + } + if s.cmd != nil { + s.cmd.Wait() + s.cmd = nil + } +} + +func (s *ALACWAVStreamer) startFFmpeg() error { + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + + audioOffset := s.offset + if audioOffset < 44 { + audioOffset = 44 + } + + timeOffset := float64(audioOffset-44) / 176400.0 + + // Use the local HTTP endpoint to fetch the raw ALAC stream. + // This allows ffmpeg to seek efficiently using HTTP Range requests! + url := fmt.Sprintf("http://127.0.0.1:%s/tracks/%s?format=raw", s.port, s.track.ID) + + args := []string{ + "-hide_banner", "-loglevel", "error", + } + if timeOffset > 0 { + args = append(args, "-ss", fmt.Sprintf("%.3f", timeOffset)) + } + args = append(args, + "-i", url, + "-c:a", "pcm_s16le", + "-ar", "44100", + "-ac", "2", + "-f", "wav", + "pipe:1", + ) + + s.cmd = exec.CommandContext(ctx, "ffmpeg", args...) + + stdout, err := s.cmd.StdoutPipe() + if err != nil { + cancel() + return err + } + s.stdout = stdout + + if err := s.cmd.Start(); err != nil { + cancel() + return err + } + + // Skip the 44-byte WAV header generated by ffmpeg + skipBuf := make([]byte, 44) + _, _ = io.ReadFull(s.stdout, skipBuf) + + return nil +} + +func (s *ALACWAVStreamer) Read(p []byte) (n int, err error) { + if s.offset >= s.totalSize { + return 0, io.EOF + } + + bytesRead := 0 + + // 1. Serve synthesized header if offset is within [0, 44) + if s.offset < 44 { + copied := copy(p, s.header[s.offset:]) + s.offset += int64(copied) + bytesRead += copied + + if len(p) == bytesRead { + return bytesRead, nil + } + // If buffer still has space, continue to read from ffmpeg + p = p[copied:] + } + + // 2. Serve from ffmpeg + if s.stdout == nil { + if err := s.startFFmpeg(); err != nil { + return bytesRead, err + } + } + + // Cap read to totalSize + remaining := s.totalSize - s.offset + if int64(len(p)) > remaining { + p = p[:remaining] + } + + n2, err2 := s.stdout.Read(p) + if err2 == io.EOF { + // ffmpeg finished early (e.g., duration was slightly less than estimated). + // Pad with zeros to perfectly fulfill the promised Content-Length. + s.closeFFmpeg() + for i := n2; i < len(p); i++ { + p[i] = 0 + } + n2 = len(p) + err2 = nil + } + + s.offset += int64(n2) + bytesRead += n2 + + return bytesRead, err2 +} + +func (s *ALACWAVStreamer) Close() error { + s.closeFFmpeg() + return nil +} + +// GetTotalSize returns the exact pre-calculated size of the WAV stream. +func (s *ALACWAVStreamer) GetTotalSize() int64 { + return s.totalSize +} diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index 4a90724..d6bcf77 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -213,19 +213,54 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac return true } - // 3. If track is ALAC (or FLAC requested) and needs decoding, ensure FLAC is ready + // 3. If track is ALAC (or FLAC requested) and needs decoding, ensure FLAC is ready or stream WAV if s.alacService != nil && s.alacService.ShouldDecodeALAC(r, track) { - flacPath, err := s.alacService.EnsureDecodedFLAC(ctx, track) - if err == nil && flacPath != "" { - if serveCachedFLAC(flacPath) { - return - } - } else if err != nil { - logStream.Warnf("Failed to transcode ALAC track %s to FLAC: %v; falling back to direct stream", track.ID, err) + // Check if local cache file already exists + cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) + if serveCachedFLAC(cacheFile) { + return + } + + // Kick off background FLAC cache (EnsureDecodedFLAC already has internal locking) + go s.alacService.EnsureDecodedFLAC(context.Background(), track) + + // Serve on-the-fly WAV stream with full seek support + wavStreamer := NewALACWAVStreamer(track, s.alacService.cfg.Port) + defer wavStreamer.Close() + + // Increment play count asynchronously + go func(tid string) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.repo.IncrementPlayCount(timeoutCtx, tid) + }(track.ID) + + cleanBase := track.Audio.Title + if track.Audio.Artist != "" { + cleanBase = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) } + if cleanBase == "" { + cleanBase = track.Telegram.FileName + } + if cleanBase == "" { + cleanBase = track.ID + } + cleanBase = strings.TrimSuffix(cleanBase, filepath.Ext(cleanBase)) + wavFilename := fmt.Sprintf("%s.wav", cleanBase) + + fallback := strings.ReplaceAll(wavFilename, `"`, `_`) + encoded := url.QueryEscape(wavFilename) + + w.Header().Set("Content-Type", "audio/wav") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + + // http.ServeContent handles all Range parsing and chunking automatically! + http.ServeContent(w, r, wavFilename, time.Now(), wavStreamer) + return } - // Check if local cache file exists (e.g. decoded ALAC/FLAC) + // Double check cache (if we reached here by some other path) cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) if serveCachedFLAC(cacheFile) { return @@ -521,5 +556,3 @@ func (s *StreamService) WarmTrack(ctx context.Context, id string) { } logStream.Infof("Prewarming track %s (%s - %s)", track.ID, track.Audio.Artist, track.Audio.Title) } - - From a4ca79d9ce77eaf32da2d0139652c11bf3b65fa7 Mon Sep 17 00:00:00 2001 From: Rishav Ghosh1 Date: Tue, 22 Sep 2026 19:29:51 +0530 Subject: [PATCH 14/39] update alac stream url --- internal/services/alac_wav_streamer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/services/alac_wav_streamer.go b/internal/services/alac_wav_streamer.go index 715dee0..7c6b26a 100644 --- a/internal/services/alac_wav_streamer.go +++ b/internal/services/alac_wav_streamer.go @@ -109,7 +109,7 @@ func (s *ALACWAVStreamer) startFFmpeg() error { // Use the local HTTP endpoint to fetch the raw ALAC stream. // This allows ffmpeg to seek efficiently using HTTP Range requests! - url := fmt.Sprintf("http://127.0.0.1:%s/tracks/%s?format=raw", s.port, s.track.ID) + url := fmt.Sprintf("http://127.0.0.1:%s/stream/%s?format=raw", s.port, s.track.ID) args := []string{ "-hide_banner", "-loglevel", "error", From 3fb6dbdeddb0654615fc76965f63561207a23eb8 Mon Sep 17 00:00:00 2001 From: Rishav Ghosh1 Date: Tue, 22 Sep 2026 23:06:48 +0530 Subject: [PATCH 15/39] fix(audio): fix ALAC seek noise caused by mid-stream WAV->FLAC switch and FFmpeg WAV metadata headers --- internal/services/alac_service.go | 32 ++++++++++++++++++++++-- internal/services/alac_wav_streamer.go | 34 ++++++++++++++++---------- internal/services/stream_service.go | 34 ++++++++++++++++++++++---- 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/internal/services/alac_service.go b/internal/services/alac_service.go index 02cb4cd..288d385 100644 --- a/internal/services/alac_service.go +++ b/internal/services/alac_service.go @@ -30,8 +30,36 @@ type ALACService struct { maxBytes int64 maxFiles int - locks sync.Map // trackID -> *sync.Mutex - pruneMu sync.Mutex + locks sync.Map // trackID -> *sync.Mutex + wavSessions sync.Map // trackID -> time.Time (expiry) + pruneMu sync.Mutex +} + +// SetWAVSession marks that a track is actively being streamed as WAV. +func (s *ALACService) SetWAVSession(trackID string, duration time.Duration) { + if duration <= 0 { + duration = 10 * time.Minute + } + s.wavSessions.Store(trackID, time.Now().Add(duration)) +} + +// HasActiveWAVSession checks if a track is currently in an active WAV playback session. +func (s *ALACService) HasActiveWAVSession(trackID string) bool { + val, ok := s.wavSessions.Load(trackID) + if !ok { + return false + } + expiry, ok := val.(time.Time) + if !ok || time.Now().After(expiry) { + s.wavSessions.Delete(trackID) + return false + } + return true +} + +// ClearWAVSession clears the active WAV playback session for a track. +func (s *ALACService) ClearWAVSession(trackID string) { + s.wavSessions.Delete(trackID) } // NewALACService creates and initializes a new ALACService. diff --git a/internal/services/alac_wav_streamer.go b/internal/services/alac_wav_streamer.go index 7c6b26a..91a21c6 100644 --- a/internal/services/alac_wav_streamer.go +++ b/internal/services/alac_wav_streamer.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "streamgo/internal/models" ) @@ -15,6 +16,7 @@ type ALACWAVStreamer struct { totalSize int64 offset int64 port string + cacheFile string cmd *exec.Cmd stdout io.ReadCloser @@ -24,7 +26,7 @@ type ALACWAVStreamer struct { } // NewALACWAVStreamer creates a new ReadSeekCloser that transcodes an ALAC track to WAV on the fly. -func NewALACWAVStreamer(track *models.Track, port string) *ALACWAVStreamer { +func NewALACWAVStreamer(track *models.Track, port string, cacheFile string) *ALACWAVStreamer { durationSec := track.Audio.DurationSec if durationSec <= 0 { durationSec = 300 // fallback 5 mins @@ -52,6 +54,7 @@ func NewALACWAVStreamer(track *models.Track, port string) *ALACWAVStreamer { track: track, totalSize: totalSize, port: port, + cacheFile: cacheFile, header: header, } } @@ -105,24 +108,31 @@ func (s *ALACWAVStreamer) startFFmpeg() error { audioOffset = 44 } - timeOffset := float64(audioOffset-44) / 176400.0 + // Align to 4-byte sample frame boundary (16-bit stereo = 2 bytes * 2 channels) + alignedAudioOffset := 44 + ((audioOffset-44)/4)*4 + timeOffset := float64(alignedAudioOffset-44) / 176400.0 - // Use the local HTTP endpoint to fetch the raw ALAC stream. - // This allows ffmpeg to seek efficiently using HTTP Range requests! - url := fmt.Sprintf("http://127.0.0.1:%s/stream/%s?format=raw", s.port, s.track.ID) + // Use local FLAC file directly if it exists and is complete; otherwise stream raw track on-the-fly + inputSource := fmt.Sprintf("http://127.0.0.1:%s/stream/%s?format=raw", s.port, s.track.ID) + if s.cacheFile != "" { + if info, err := os.Stat(s.cacheFile); err == nil && info.Size() > 10240 { + inputSource = s.cacheFile + } + } args := []string{ "-hide_banner", "-loglevel", "error", } if timeOffset > 0 { - args = append(args, "-ss", fmt.Sprintf("%.3f", timeOffset)) + args = append(args, "-ss", fmt.Sprintf("%.6f", timeOffset)) } args = append(args, - "-i", url, + "-i", inputSource, + "-vn", "-c:a", "pcm_s16le", "-ar", "44100", "-ac", "2", - "-f", "wav", + "-f", "s16le", "pipe:1", ) @@ -140,10 +150,8 @@ func (s *ALACWAVStreamer) startFFmpeg() error { return err } - // Skip the 44-byte WAV header generated by ffmpeg - skipBuf := make([]byte, 44) - _, _ = io.ReadFull(s.stdout, skipBuf) - + // With -f s16le, FFmpeg writes pure raw PCM audio samples with NO headers. + // All bytes from s.stdout are pure PCM audio frames. return nil } @@ -183,7 +191,7 @@ func (s *ALACWAVStreamer) Read(p []byte) (n int, err error) { n2, err2 := s.stdout.Read(p) if err2 == io.EOF { // ffmpeg finished early (e.g., duration was slightly less than estimated). - // Pad with zeros to perfectly fulfill the promised Content-Length. + // Pad with zeros to fulfill the promised Content-Length. s.closeFFmpeg() for i := n2; i < len(p); i++ { p[i] = 0 diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index d6bcf77..d5e2ecc 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -215,17 +215,41 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac // 3. If track is ALAC (or FLAC requested) and needs decoding, ensure FLAC is ready or stream WAV if s.alacService != nil && s.alacService.ShouldDecodeALAC(r, track) { - // Check if local cache file already exists cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) - if serveCachedFLAC(cacheFile) { - return + rangeHeader := strings.TrimSpace(r.Header.Get("Range")) + + isFreshPlay := rangeHeader == "" || strings.HasPrefix(rangeHeader, "bytes=0-") + hasWAVSession := s.alacService.HasActiveWAVSession(track.ID) + + // If this is a fresh start from the beginning (not seeking in an ongoing WAV stream), + // clear any stale WAV session and serve the cached FLAC if ready: + if isFreshPlay { + s.alacService.ClearWAVSession(track.ID) + hasWAVSession = false + } + + if !hasWAVSession { + if serveCachedFLAC(cacheFile) { + return + } + } + + // Otherwise, we are either: + // 1) Starting a fresh playback where FLAC is not yet cached, OR + // 2) Continuing / seeking within an ongoing WAV playback session. + // Keep serving WAV to maintain consistent MIME type and byte offsets! + + sessionTTL := time.Duration(track.Audio.DurationSec+120) * time.Second + if sessionTTL < 5*time.Minute { + sessionTTL = 5 * time.Minute } + s.alacService.SetWAVSession(track.ID, sessionTTL) - // Kick off background FLAC cache (EnsureDecodedFLAC already has internal locking) + // Kick off background FLAC cache if not already running go s.alacService.EnsureDecodedFLAC(context.Background(), track) // Serve on-the-fly WAV stream with full seek support - wavStreamer := NewALACWAVStreamer(track, s.alacService.cfg.Port) + wavStreamer := NewALACWAVStreamer(track, s.alacService.cfg.Port, cacheFile) defer wavStreamer.Close() // Increment play count asynchronously From ac0d436a714d7c84f06b0ee16c8fc1aba7bcc6d8 Mon Sep 17 00:00:00 2001 From: Rishav Ghosh1 Date: Wed, 23 Sep 2026 15:35:29 +0530 Subject: [PATCH 16/39] fix(audio): eliminate dual-format stream collision by consistently serving WAV for browser ALAC playback --- internal/services/alac_service.go | 33 ++-------------------- internal/services/stream_service.go | 43 ++++++----------------------- 2 files changed, 11 insertions(+), 65 deletions(-) diff --git a/internal/services/alac_service.go b/internal/services/alac_service.go index 288d385..ba8356e 100644 --- a/internal/services/alac_service.go +++ b/internal/services/alac_service.go @@ -30,36 +30,8 @@ type ALACService struct { maxBytes int64 maxFiles int - locks sync.Map // trackID -> *sync.Mutex - wavSessions sync.Map // trackID -> time.Time (expiry) - pruneMu sync.Mutex -} - -// SetWAVSession marks that a track is actively being streamed as WAV. -func (s *ALACService) SetWAVSession(trackID string, duration time.Duration) { - if duration <= 0 { - duration = 10 * time.Minute - } - s.wavSessions.Store(trackID, time.Now().Add(duration)) -} - -// HasActiveWAVSession checks if a track is currently in an active WAV playback session. -func (s *ALACService) HasActiveWAVSession(trackID string) bool { - val, ok := s.wavSessions.Load(trackID) - if !ok { - return false - } - expiry, ok := val.(time.Time) - if !ok || time.Now().After(expiry) { - s.wavSessions.Delete(trackID) - return false - } - return true -} - -// ClearWAVSession clears the active WAV playback session for a track. -func (s *ALACService) ClearWAVSession(trackID string) { - s.wavSessions.Delete(trackID) + locks sync.Map // trackID -> *sync.Mutex + pruneMu sync.Mutex } // NewALACService creates and initializes a new ALACService. @@ -371,4 +343,3 @@ func (s *ALACService) PruneCacheIfNeeded() { logALAC.Infof("[ALAC-Cache-LRU] Pruned %d stale track(s), freed %.2f MB", prunedCount, float64(prunedBytes)/(1024*1024)) } } - diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index d5e2ecc..92b57af 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -213,42 +213,14 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac return true } - // 3. If track is ALAC (or FLAC requested) and needs decoding, ensure FLAC is ready or stream WAV + // 3. If track is ALAC (or FLAC requested) and needs decoding, serve consistent WAV stream if s.alacService != nil && s.alacService.ShouldDecodeALAC(r, track) { cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) - rangeHeader := strings.TrimSpace(r.Header.Get("Range")) - - isFreshPlay := rangeHeader == "" || strings.HasPrefix(rangeHeader, "bytes=0-") - hasWAVSession := s.alacService.HasActiveWAVSession(track.ID) - - // If this is a fresh start from the beginning (not seeking in an ongoing WAV stream), - // clear any stale WAV session and serve the cached FLAC if ready: - if isFreshPlay { - s.alacService.ClearWAVSession(track.ID) - hasWAVSession = false - } - - if !hasWAVSession { - if serveCachedFLAC(cacheFile) { - return - } - } - - // Otherwise, we are either: - // 1) Starting a fresh playback where FLAC is not yet cached, OR - // 2) Continuing / seeking within an ongoing WAV playback session. - // Keep serving WAV to maintain consistent MIME type and byte offsets! - - sessionTTL := time.Duration(track.Audio.DurationSec+120) * time.Second - if sessionTTL < 5*time.Minute { - sessionTTL = 5 * time.Minute - } - s.alacService.SetWAVSession(track.ID, sessionTTL) // Kick off background FLAC cache if not already running go s.alacService.EnsureDecodedFLAC(context.Background(), track) - // Serve on-the-fly WAV stream with full seek support + // Serve on-the-fly WAV stream with full seek support (reads from local cacheFile if ready, else on-the-fly) wavStreamer := NewALACWAVStreamer(track, s.alacService.cfg.Port, cacheFile) defer wavStreamer.Close() @@ -284,10 +256,13 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac return } - // Double check cache (if we reached here by some other path) - cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) - if serveCachedFLAC(cacheFile) { - return + // For non-decoded tracks, do not hijack with cached FLAC if raw format was explicitly requested + rawParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if rawParam != "raw" && rawParam != "alac" && rawParam != "original" && rawParam != "source" { + cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) + if serveCachedFLAC(cacheFile) { + return + } } // 4. Handle Range Header From 62f0f087b1fed77374f72d582647ed8c82ad21bb Mon Sep 17 00:00:00 2001 From: Rishav Ghosh1 Date: Wed, 23 Sep 2026 23:07:07 +0530 Subject: [PATCH 17/39] fix(stream): handle malformed FLAC picture headers and broaden ALAC detection --- internal/api/handlers/stream.go | 3 + internal/services/alac_service.go | 27 ++++++++ internal/services/alac_service_test.go | 51 ++++++++++++++++ internal/services/mediainfo.go | 4 +- internal/services/stream_service.go | 85 ++++++++++++++++++++++++-- 5 files changed, 164 insertions(+), 6 deletions(-) diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index 2086ddd..b4e42e3 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -75,6 +75,9 @@ func (h *StreamHandler) Download(w http.ResponseWriter, r *http.Request) { if (h.streamService.ALACService() != nil && h.streamService.ALACService().ShouldDecodeALAC(r, track)) || strings.ToLower(r.URL.Query().Get("format")) == "flac" { ext = "flac" } + if ext == "alac" { + ext = "m4a" + } if ext == "" { ext = "mp3" } diff --git a/internal/services/alac_service.go b/internal/services/alac_service.go index ba8356e..abf9be1 100644 --- a/internal/services/alac_service.go +++ b/internal/services/alac_service.go @@ -95,6 +95,33 @@ func (s *ALACService) IsALACTrack(track *models.Track) bool { return true } + // Check if already transcoded into the FLAC cache + if s.cacheDir != "" { + cacheFile := filepath.Join(s.cacheDir, fmt.Sprintf("%s.flac", track.ID)) + if info, err := os.Stat(cacheFile); err == nil && info.Size() > 10240 { + return true + } + } + + // M4A/MP4 container analysis: + // Lossy AAC never has a bit depth in MediaInfo; ALAC is PCM-based lossless with explicit bit depth (16 or 24-bit). + // In addition, standard 2-channel stereo AAC bitrates max out at 320 kbps, whereas lossless ALAC is typically 500-1400+ kbps. + isM4A := typeStr == "m4a" || typeStr == "mp4" || mimeStr == "audio/mp4" || mimeStr == "audio/x-m4a" || strings.HasSuffix(nameStr, ".m4a") + if isM4A { + if track.Audio.BitDepth != nil && *track.Audio.BitDepth > 0 { + return true + } + if track.Audio.BitrateKbps != nil && *track.Audio.BitrateKbps > 450 { + return true + } + if track.Audio.DurationSec > 0 && track.Telegram.FileSize > 0 { + calcKbps := (track.Telegram.FileSize * 8) / (int64(track.Audio.DurationSec) * 1000) + if calcKbps > 450 { + return true + } + } + } + return false } diff --git a/internal/services/alac_service_test.go b/internal/services/alac_service_test.go index eb33c6f..5ca00de 100644 --- a/internal/services/alac_service_test.go +++ b/internal/services/alac_service_test.go @@ -69,6 +69,57 @@ func TestIsALACTrack(t *testing.T) { }, expected: false, }, + { + name: "M4A with BitDepth (ALAC lossless)", + track: &models.Track{ + Audio: models.AudioMeta{ + Type: "m4a", + BitDepth: func() *int32 { v := int32(16); return &v }(), + }, + Telegram: models.TelegramMeta{MimeType: "audio/mp4"}, + }, + expected: true, + }, + { + name: "M4A with Lossless Bitrate (> 450 kbps)", + track: &models.Track{ + Audio: models.AudioMeta{ + Type: "m4a", + BitrateKbps: func() *int32 { v := int32(761); return &v }(), + }, + Telegram: models.TelegramMeta{MimeType: "audio/mp4"}, + }, + expected: true, + }, + { + name: "M4A with Calculated High Bitrate from FileSize and Duration", + track: &models.Track{ + Audio: models.AudioMeta{ + Type: "m4a", + DurationSec: 155, + }, + Telegram: models.TelegramMeta{ + MimeType: "audio/mp4", + FileSize: 15178406, + }, + }, + expected: true, + }, + { + name: "M4A Standard Lossy AAC (256 kbps, no bit depth)", + track: &models.Track{ + Audio: models.AudioMeta{ + Type: "m4a", + BitrateKbps: func() *int32 { v := int32(256); return &v }(), + DurationSec: 200, + }, + Telegram: models.TelegramMeta{ + MimeType: "audio/mp4", + FileSize: 6400000, + }, + }, + expected: false, + }, } for _, tt := range tests { diff --git a/internal/services/mediainfo.go b/internal/services/mediainfo.go index 810047a..afedebd 100644 --- a/internal/services/mediainfo.go +++ b/internal/services/mediainfo.go @@ -232,7 +232,9 @@ func normalizeAudioFormat(format string) string { return "flac" case strings.Contains(f, "mpeg") || strings.Contains(f, "layer 3") || f == "mp3": return "mp3" - case strings.Contains(f, "aac") || strings.Contains(f, "alac") || strings.Contains(f, "mp4"): + case strings.Contains(f, "alac"): + return "alac" + case strings.Contains(f, "aac") || strings.Contains(f, "mp4"): return "m4a" case strings.Contains(f, "ogg") || strings.Contains(f, "vorbis") || strings.Contains(f, "opus"): return "ogg" diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index 92b57af..49365ec 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/binary" "errors" "fmt" "net/http" @@ -152,20 +153,61 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac totalSize = 10 * 1024 * 1024 } - // 2. Determine mime type + // 2. Determine mime type and normalize vendor mime types + audioType := strings.ToLower(strings.TrimSpace(track.Audio.Type)) mimeType := track.Audio.MimeType if mimeType == "" { mimeType = track.Telegram.MimeType } + switch { + case audioType == "flac" || mimeType == "audio/x-flac" || mimeType == "audio/flac": + mimeType = "audio/flac" + case audioType == "wav" || mimeType == "audio/x-wav" || mimeType == "audio/wav": + mimeType = "audio/wav" + case audioType == "mp3" || audioType == "mpeg" || mimeType == "audio/mp3": + mimeType = "audio/mpeg" + case audioType == "alac" || audioType == "m4a" || audioType == "aac" || mimeType == "audio/x-m4a" || mimeType == "audio/mp4": + mimeType = "audio/mp4" + case audioType == "ogg" || audioType == "opus": + mimeType = "audio/ogg" + } if mimeType == "" { mimeType = "audio/mpeg" } - // 3. Clean filename for Content-Disposition - filename := track.Telegram.FileName - if filename == "" { - filename = fmt.Sprintf("%s.mp3", track.ID) + // 3. Clean filename for Content-Disposition with proper audio extension + cleanBase := "" + if track.Audio.Title != "" { + if track.Audio.Artist != "" { + cleanBase = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } else { + cleanBase = track.Audio.Title + } + } else if track.Telegram.FileName != "" { + cleanBase = strings.TrimSuffix(track.Telegram.FileName, filepath.Ext(track.Telegram.FileName)) + } else { + cleanBase = track.ID + } + + ext := audioType + if ext == "alac" { + ext = "m4a" + } + if ext == "" { + switch mimeType { + case "audio/flac": + ext = "flac" + case "audio/wav": + ext = "wav" + case "audio/mp4": + ext = "m4a" + case "audio/ogg": + ext = "ogg" + default: + ext = "mp3" + } } + filename := fmt.Sprintf("%s.%s", cleanBase, ext) serveCachedFLAC := func(filePath string) bool { info, statErr := os.Stat(filePath) @@ -523,6 +565,11 @@ func (s *StreamService) streamFromTelegram( data = data[:bytesRemaining] } + // Sanitize corrupted FLAC metadata blocks (e.g. picture block with mime_len <= 0) on the first chunk + if startOffset == 0 && chunkOffset == 0 && len(data) >= 8 && string(data[:4]) == "fLaC" { + sanitizeFLACHeader(data) + } + written, writeErr := w.Write(data) if flusher != nil { flusher.Flush() @@ -555,3 +602,31 @@ func (s *StreamService) WarmTrack(ctx context.Context, id string) { } logStream.Infof("Prewarming track %s (%s - %s)", track.ID, track.Audio.Artist, track.Audio.Title) } + +// sanitizeFLACHeader inspects FLAC metadata blocks and converts corrupt picture blocks (mime_len <= 0) +// into harmless PADDING blocks so that Chromium/Edge demuxers don't abort with AVERROR_INVALIDDATA. +func sanitizeFLACHeader(data []byte) { + if len(data) < 8 || string(data[:4]) != "fLaC" { + return + } + pos := 4 + for pos+4 <= len(data) { + b0 := data[pos] + isLast := (b0 & 0x80) != 0 + blockType := b0 & 0x7F + length := int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3]) + if blockType == 6 { // PICTURE block + if pos+4+8 <= len(data) { + mimeLen := int(binary.BigEndian.Uint32(data[pos+4+4 : pos+4+8])) + if mimeLen <= 0 { + data[pos] = (b0 & 0x80) | 1 // Convert to block type 1 (PADDING) + logStream.Infof("Sanitized corrupted FLAC picture block (mime_len=%d) to PADDING at offset %d", mimeLen, pos) + } + } + } + pos += 4 + length + if isLast { + break + } + } +} From f5b5bbf1723e0f78c0a03e48cbe16504cbc98205 Mon Sep 17 00:00:00 2001 From: Kousik Date: Fri, 25 Sep 2026 13:56:20 +0530 Subject: [PATCH 18/39] feat: add support for recaps, listening calculations, and stream playback history --- cmd/server/main.go | 2 + internal/api/handlers/history.go | 7 +- internal/api/handlers/recap.go | 322 +++++++ internal/api/handlers/recap_test.go | 215 +++++ internal/api/handlers/stream.go | 79 +- internal/api/handlers/stream_test.go | 135 +++ internal/api/middleware/auth.go | 7 +- internal/models/recap.go | 191 ++++ internal/repository/history_repo.go | 48 +- internal/repository/recap_repo.go | 589 ++++++++++++ internal/server/router.go | 6 +- internal/server/spa.go | 1 + internal/services/enrichment_service.go | 5 + internal/services/recap_service.go | 1105 +++++++++++++++++++++++ internal/services/recap_service_test.go | 469 ++++++++++ internal/services/stream_service.go | 48 +- internal/telegram/client.go | 23 +- 17 files changed, 3205 insertions(+), 47 deletions(-) create mode 100644 internal/api/handlers/recap.go create mode 100644 internal/api/handlers/recap_test.go create mode 100644 internal/api/handlers/stream_test.go create mode 100644 internal/models/recap.go create mode 100644 internal/repository/recap_repo.go create mode 100644 internal/services/recap_service.go create mode 100644 internal/services/recap_service_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 242b766..1716ef7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -109,6 +109,7 @@ func main() { sig := <-quit log.Infof("received shutdown signal (%s), starting graceful shutdown...", sig) + cancel() // Concurrently signals background workers (enrichment, telegram) to abort shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() @@ -119,5 +120,6 @@ func main() { log.Info("HTTP server shutdown cleanly.") } + enrichSvc.Stop() log.Info("StreamGO stopped. Goodbye!") } diff --git a/internal/api/handlers/history.go b/internal/api/handlers/history.go index e646e3a..f30b950 100644 --- a/internal/api/handlers/history.go +++ b/internal/api/handlers/history.go @@ -26,19 +26,14 @@ func NewHistoryHandler(histSvc *services.HistoryService, authSvc *services.AuthS } } -// Routes mounts history and playback telemetry endpoints. +// Routes mounts history endpoints. func (h *HistoryHandler) Routes(r chi.Router) { - optionalAuth := middleware.OptionalAuth(h.authSvc) requireAuth := middleware.RequireAuth(h.authSvc) // History endpoints r.With(requireAuth).Get("/history", h.GetHistory) r.With(requireAuth).Get("/me/history", h.GetHistory) r.With(requireAuth).Get("/me/top-played", h.GetTopPlayed) - - // Telemetry / listening events - r.With(optionalAuth).Post("/me/listening-events", h.RecordEvents) - r.With(optionalAuth).Post("/listening-events", h.RecordEvents) } // GetHistory handles GET /history and GET /me/history. diff --git a/internal/api/handlers/recap.go b/internal/api/handlers/recap.go new file mode 100644 index 0000000..b2d66c8 --- /dev/null +++ b/internal/api/handlers/recap.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// RecapHandler handles user listening events, recaps, period snapshots, and public share links. +type RecapHandler struct { + recapSvc *services.RecapService + authSvc *services.AuthService +} + +// NewRecapHandler creates a new RecapHandler. +func NewRecapHandler(recapSvc *services.RecapService, authSvc *services.AuthService) *RecapHandler { + return &RecapHandler{ + recapSvc: recapSvc, + authSvc: authSvc, + } +} + +// Routes mounts recap and listening event endpoints. +func (h *RecapHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + optionalAuth := middleware.OptionalAuth(h.authSvc) + + // Listening events telemetry + r.With(requireAuth).Post("/me/listening-events", h.RecordListeningEvents) + r.With(optionalAuth).Post("/listening-events", h.RecordLegacyListeningEvents) + + // Recaps collection and shares management + r.With(requireAuth).Get("/me/recaps", h.ListRecaps) + r.With(requireAuth).Get("/me/recaps/shares", h.ListShares) + r.With(requireAuth).Delete("/me/recaps/shares/{token}", h.RevokeShare) + r.With(requireAuth).Delete("/me/recaps/data", h.DeleteData) + + // Specific recap snapshots & share generation + r.With(requireAuth).Get("/me/recaps/{ptype}/{period}", h.GetRecap) + r.With(requireAuth).Post("/me/recaps/{ptype}/{period}/share", h.ShareRecap) + + // Public share viewer (no auth required) + r.Get("/recaps/share/{token}", h.GetPublicShare) +} + +// RecordListeningEvents handles POST /me/listening-events. +func (h *RecapHandler) RecordListeningEvents(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(payload.Events) > 200 { + api.RespondError(w, http.StatusBadRequest, "batch size cannot exceed 200 events") + return + } + + stored, err := h.recapSvc.RecordEvents(r.Context(), userID, payload.Events) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "received": len(payload.Events), + "stored": stored, + }) +} + +// RecordLegacyListeningEvents handles POST /listening-events. +func (h *RecapHandler) RecordLegacyListeningEvents(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(payload.Events) == 0 { + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "count": 0}) + return + } + + if userID > 0 { + _, _ = h.recapSvc.RecordEvents(r.Context(), userID, payload.Events) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(payload.Events), + }) +} + +// ListRecaps handles GET /me/recaps?tz=. +func (h *RecapHandler) ListRecaps(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + tz := api.ParseQueryInt(r, "tz", 0) + if tz < -840 || tz > 840 { + api.RespondError(w, http.StatusBadRequest, "tz offset out of range (-840 to 840 minutes)") + return + } + + items, err := h.recapSvc.ListAvailable(r.Context(), userID, tz) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if items == nil { + items = []*models.RecapAvailableItem{} + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "items": items, + }) +} + +// ListShares handles GET /me/recaps/shares. +func (h *RecapHandler) ListShares(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + items, err := h.recapSvc.ListShares(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if items == nil { + items = []*models.RecapShareItem{} + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "items": items, + }) +} + +// RevokeShare handles DELETE /me/recaps/shares/{token}. +func (h *RecapHandler) RevokeShare(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + token := chi.URLParam(r, "token") + if token == "" { + api.RespondError(w, http.StatusBadRequest, "token is required") + return + } + + revoked, err := h.recapSvc.RevokeShare(r.Context(), userID, token) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if !revoked { + api.RespondError(w, http.StatusNotFound, "Share link not found") + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +// DeleteData handles DELETE /me/recaps/data. +func (h *RecapHandler) DeleteData(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + counts, err := h.recapSvc.DeleteUserData(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "events": counts["events"], + "snapshots": counts["snapshots"], + "shares": counts["shares"], + }) +} + +// GetRecap handles GET /me/recaps/{ptype}/{period}?tz=&refresh=. +func (h *RecapHandler) GetRecap(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + ptype := models.RecapPeriodType(chi.URLParam(r, "ptype")) + if ptype != models.RecapPeriodWeekly && ptype != models.RecapPeriodMonthly && ptype != models.RecapPeriodYearly { + api.RespondError(w, http.StatusBadRequest, "invalid period type, expected weekly, monthly, or yearly") + return + } + + period := chi.URLParam(r, "period") + if period == "" { + api.RespondError(w, http.StatusBadRequest, "period parameter is required") + return + } + + tz := api.ParseQueryInt(r, "tz", 0) + if tz < -840 || tz > 840 { + api.RespondError(w, http.StatusBadRequest, "tz offset out of range (-840 to 840 minutes)") + return + } + + refresh := api.ParseQueryBool(r, "refresh", false) + + if !services.IsPeriodAvailable(ptype, period, tz) { + api.RespondError(w, http.StatusBadRequest, "This recap is not ready yet. Weekly recaps unlock on the weekend (Sunday), monthly on the last day of the month, and yearly on Dec 31st.") + return + } + + snap, err := h.recapSvc.GetSnapshot(r.Context(), userID, ptype, period, tz, refresh) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, snap) +} + +// ShareRecap handles POST /me/recaps/{ptype}/{period}/share?tz=. +func (h *RecapHandler) ShareRecap(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + ptype := models.RecapPeriodType(chi.URLParam(r, "ptype")) + if ptype != models.RecapPeriodWeekly && ptype != models.RecapPeriodMonthly && ptype != models.RecapPeriodYearly { + api.RespondError(w, http.StatusBadRequest, "invalid period type, expected weekly, monthly, or yearly") + return + } + + period := chi.URLParam(r, "period") + if period == "" { + api.RespondError(w, http.StatusBadRequest, "period parameter is required") + return + } + + tz := api.ParseQueryInt(r, "tz", 0) + if tz < -840 || tz > 840 { + api.RespondError(w, http.StatusBadRequest, "tz offset out of range (-840 to 840 minutes)") + return + } + + if !services.IsPeriodAvailable(ptype, period, tz) { + api.RespondError(w, http.StatusBadRequest, "This recap is not ready to share yet.") + return + } + + snap, err := h.recapSvc.GetSnapshot(r.Context(), userID, ptype, period, tz, false) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + if snap.Stats == nil || snap.Stats.TotalPlays == 0 { + api.RespondError(w, http.StatusBadRequest, "Nothing to share for this period yet") + return + } + + shareItem, err := h.recapSvc.CreateShare(r.Context(), userID, snap) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, shareItem) +} + +// GetPublicShare handles GET /recaps/share/{token}. +func (h *RecapHandler) GetPublicShare(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + if token == "" { + api.RespondError(w, http.StatusNotFound, "This recap link is no longer available") + return + } + + summary, err := h.recapSvc.GetPublicShare(r.Context(), token) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if summary == nil { + api.RespondError(w, http.StatusNotFound, "This recap link is no longer available") + return + } + + api.RespondJSON(w, http.StatusOK, summary) +} diff --git a/internal/api/handlers/recap_test.go b/internal/api/handlers/recap_test.go new file mode 100644 index 0000000..7a8b26d --- /dev/null +++ b/internal/api/handlers/recap_test.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// mockRecapRepo implements repository.RecapRepository for testing handlers. +type mockRecapRepo struct { + events []models.ListeningEventItem + publicShare *models.RecapPublicSummary + shares []*models.RecapShareItem + revokeResult bool +} + +func (m *mockRecapRepo) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) { + m.events = append(m.events, events...) + return len(events), nil +} + +func (m *mockRecapRepo) LoadEvents(ctx context.Context, userID int64, start float64, end float64) ([]*models.ListeningEventDoc, error) { + return nil, nil +} + +func (m *mockRecapRepo) FirstEventTimestamp(ctx context.Context, userID int64) (float64, bool, error) { + return 0, false, nil +} + +func (m *mockRecapRepo) HasPlays(ctx context.Context, userID int64, start float64, end float64) (bool, error) { + return false, nil +} + +func (m *mockRecapRepo) FirstSeenBefore(ctx context.Context, userID int64, trackIDs []string, before float64) (map[string]bool, error) { + return make(map[string]bool), nil +} + +func (m *mockRecapRepo) GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int) (*models.RecapSnapshot, float64, error) { + return nil, 0, nil +} + +func (m *mockRecapRepo) SaveSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int, snap *models.RecapSnapshot) error { + return nil +} + +func (m *mockRecapRepo) CreateShare(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, summary *models.RecapPublicSummary) (*models.RecapShareItem, error) { + return &models.RecapShareItem{ + Token: "test_token_123", + Type: ptype, + Period: period, + }, nil +} + +func (m *mockRecapRepo) ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) { + return m.shares, nil +} + +func (m *mockRecapRepo) RevokeShare(ctx context.Context, userID int64, token string) (bool, error) { + return m.revokeResult, nil +} + +func (m *mockRecapRepo) GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) { + return m.publicShare, nil +} + +func (m *mockRecapRepo) DeleteUserData(ctx context.Context, userID int64) (int64, int64, int64, error) { + return 5, 2, 1, nil +} + +func setupRecapTestRouter(mock *mockRecapRepo) (http.Handler, string) { + recapSvc := services.NewRecapService(mock, nil) + authSvc := services.NewAuthService(&config.Config{SecretKey: "test_recap_secret"}, nil) + handler := NewRecapHandler(recapSvc, authSvc) + + token, _ := authSvc.CreateToken(&models.User{ + ID: 12345, + Username: "recapuser", + }) + + r := chi.NewRouter() + handler.Routes(r) + return r, token +} + +func TestRecapHandlerPublicShare(t *testing.T) { + mock := &mockRecapRepo{ + publicShare: &models.RecapPublicSummary{ + Type: models.RecapPeriodMonthly, + Period: "2026-09", + Label: "September 2026", + TotalMinutes: 120, + TotalPlays: 30, + }, + } + router, _ := setupRecapTestRouter(mock) + + // 1. Existing public share link + req := httptest.NewRequest("GET", "/recaps/share/test_token_123", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", rr.Code, rr.Body.String()) + } + var res models.RecapPublicSummary + if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if res.Period != "2026-09" || res.TotalMinutes != 120 { + t.Errorf("unexpected public summary: %+v", res) + } + + // 2. Missing/expired share link + mock.publicShare = nil + req404 := httptest.NewRequest("GET", "/recaps/share/expired_token", nil) + rr404 := httptest.NewRecorder() + router.ServeHTTP(rr404, req404) + + if rr404.Code != http.StatusNotFound { + t.Fatalf("expected 404 Not Found, got %d", rr404.Code) + } +} + +func TestRecapHandlerUnauthenticated(t *testing.T) { + mock := &mockRecapRepo{} + router, _ := setupRecapTestRouter(mock) + + // GET /me/recaps without user context -> 401 + req := httptest.NewRequest("GET", "/me/recaps", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401 Unauthorized, got %d", rr.Code) + } + + // POST /me/listening-events without user context -> 401 + reqPost := httptest.NewRequest("POST", "/me/listening-events", strings.NewReader(`{"events":[]}`)) + rrPost := httptest.NewRecorder() + router.ServeHTTP(rrPost, reqPost) + + if rrPost.Code != http.StatusUnauthorized { + t.Errorf("expected 401 Unauthorized for /me/listening-events, got %d", rrPost.Code) + } +} + +func TestRecapHandlerAuthenticatedFlows(t *testing.T) { + mock := &mockRecapRepo{ + revokeResult: true, + } + router, token := setupRecapTestRouter(mock) + + // Helper to attach Auth token header + authReq := func(method, target string, body string) *http.Request { + r := httptest.NewRequest(method, target, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + return r + } + + // 1. POST /me/listening-events + evPayload := `{"events":[{"id":"e1","track_id":"t1","played_at":1700000000,"played_ms":45000,"duration_ms":120000}]}` + reqEv := authReq("POST", "/me/listening-events", evPayload) + rrEv := httptest.NewRecorder() + router.ServeHTTP(rrEv, reqEv) + + if rrEv.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", rrEv.Code, rrEv.Body.String()) + } + var evRes map[string]any + _ = json.Unmarshal(rrEv.Body.Bytes(), &evRes) + if evRes["ok"] != true || int(evRes["stored"].(float64)) != 1 { + t.Errorf("expected ok=true, stored=1, got %+v", evRes) + } + + // 2. DELETE /me/recaps/shares/token123 + reqRevoke := authReq("DELETE", "/me/recaps/shares/token123", "") + rrRevoke := httptest.NewRecorder() + router.ServeHTTP(rrRevoke, reqRevoke) + + if rrRevoke.Code != http.StatusOK { + t.Fatalf("expected 200 OK on revoke, got %d: %s", rrRevoke.Code, rrRevoke.Body.String()) + } + + // 3. DELETE /me/recaps/data + reqDel := authReq("DELETE", "/me/recaps/data", "") + rrDel := httptest.NewRecorder() + router.ServeHTTP(rrDel, reqDel) + + if rrDel.Code != http.StatusOK { + t.Fatalf("expected 200 OK on delete data, got %d: %s", rrDel.Code, rrDel.Body.String()) + } + var delRes map[string]any + _ = json.Unmarshal(rrDel.Body.Bytes(), &delRes) + if delRes["ok"] != true || int(delRes["events"].(float64)) != 5 { + t.Errorf("expected ok=true, events=5, got %+v", delRes) + } + + // 4. Invalid period type + reqInv := authReq("GET", "/me/recaps/daily/2026-09-21", "") + rrInv := httptest.NewRecorder() + router.ServeHTTP(rrInv, reqInv) + + if rrInv.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for invalid period type, got %d", rrInv.Code) + } +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index ceb74ca..a19643d 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -6,34 +6,46 @@ import ( "net/http" "net/url" "strings" + "sync" + "time" "github.com/go-chi/chi/v5" "streamgo/internal/api" + "streamgo/internal/api/middleware" "streamgo/internal/services" ) // StreamHandler handles audio streaming and download endpoints. type StreamHandler struct { - streamService *services.StreamService + streamService *services.StreamService + authSvc *services.AuthService + histSvc *services.HistoryService + recentlyRecorded sync.Map } // NewStreamHandler creates a new StreamHandler. -func NewStreamHandler(svc *services.StreamService) *StreamHandler { - return &StreamHandler{streamService: svc} +func NewStreamHandler(svc *services.StreamService, authSvc *services.AuthService, histSvc *services.HistoryService) *StreamHandler { + return &StreamHandler{ + streamService: svc, + authSvc: authSvc, + histSvc: histSvc, + } } // Routes mounts the streaming and download endpoints. func (h *StreamHandler) Routes(r chi.Router) { - r.Get("/tracks/{id}/stream", h.Stream) - r.Head("/tracks/{id}/stream", h.Stream) - r.Get("/stream/{id}", h.Stream) - r.Head("/stream/{id}", h.Stream) + optionalAuth := middleware.OptionalAuth(h.authSvc) + + r.With(optionalAuth).Get("/tracks/{id}/stream", h.Stream) + r.With(optionalAuth).Head("/tracks/{id}/stream", h.Stream) + r.With(optionalAuth).Get("/stream/{id}", h.Stream) + r.With(optionalAuth).Head("/stream/{id}", h.Stream) - r.Get("/tracks/{id}/download", h.Download) - r.Head("/tracks/{id}/download", h.Download) - r.Get("/download/{id}", h.Download) - r.Head("/download/{id}", h.Download) + r.With(optionalAuth).Get("/tracks/{id}/download", h.Download) + r.With(optionalAuth).Head("/tracks/{id}/download", h.Download) + r.With(optionalAuth).Get("/download/{id}", h.Download) + r.With(optionalAuth).Head("/download/{id}", h.Download) r.Get("/tracks/{id}/warm", h.Warm) } @@ -47,9 +59,51 @@ func (h *StreamHandler) Stream(w http.ResponseWriter, r *http.Request) { return } + // Record playback in userHistory and counters upon stream start + if h.histSvc != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + userID := h.extractUserID(r) + if userID > 0 { + now := float64(time.Now().Unix()) + cacheKey := fmt.Sprintf("%d:%s", userID, trackID) + shouldRecord := true + if val, ok := h.recentlyRecorded.Load(cacheKey); ok { + if lastTs, ok := val.(float64); ok && (now-lastTs < 15.0) { + shouldRecord = false + } + } + + if shouldRecord { + h.recentlyRecorded.Store(cacheKey, now) + go func(uid int64, tid string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = h.histSvc.RecordPlay(ctx, uid, tid, "direct") + }(userID, trackID) + } + } + } + h.streamService.StreamTrack(w, r, trackID) } +func (h *StreamHandler) extractUserID(r *http.Request) int64 { + if uid, ok := middleware.GetUserID(r.Context()); ok && uid > 0 { + return uid + } + if h.authSvc == nil { + return 0 + } + token := middleware.ExtractToken(r) + if token == "" { + return 0 + } + claims, err := h.authSvc.VerifyTokenClaims(token) + if err != nil || claims == nil || claims.UserID <= 0 { + return 0 + } + return claims.UserID +} + // Download handles GET and HEAD /tracks/{id}/download with Content-Disposition attachment. func (h *StreamHandler) Download(w http.ResponseWriter, r *http.Request) { trackID := chi.URLParam(r, "id") @@ -97,7 +151,8 @@ func (h *StreamHandler) Warm(w http.ResponseWriter, r *http.Request) { return } + ready := h.streamService.IsTelegramReady() go h.streamService.WarmTrack(context.Background(), trackID) - api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "ready": ready}) } diff --git a/internal/api/handlers/stream_test.go b/internal/api/handlers/stream_test.go new file mode 100644 index 0000000..10e1e33 --- /dev/null +++ b/internal/api/handlers/stream_test.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "context" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +type mockStreamTrackRepo struct { + track *models.Track +} + +func (m *mockStreamTrackRepo) GetByID(ctx context.Context, id string) (*models.Track, error) { + if m.track != nil && m.track.ID == id { + return m.track, nil + } + return nil, nil +} +func (m *mockStreamTrackRepo) GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) { + return nil, nil +} +func (m *mockStreamTrackRepo) List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) { + return nil, 0, nil +} +func (m *mockStreamTrackRepo) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) { + return nil, nil +} +func (m *mockStreamTrackRepo) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) { + return nil, nil +} +func (m *mockStreamTrackRepo) GetTopics(ctx context.Context, channelID int64, limit int) ([]*models.TopicItem, error) { + return nil, nil +} +func (m *mockStreamTrackRepo) GetChannelIDs(ctx context.Context) ([]int64, error) { + return nil, nil +} +func (m *mockStreamTrackRepo) IncrementPlayCount(ctx context.Context, id string) error { + return nil +} +func (m *mockStreamTrackRepo) UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error { + return nil +} +func (m *mockStreamTrackRepo) UpdateLyricsCache(ctx context.Context, id string, text, kind, source string, telegraphURL string) error { + return nil +} + +type mockHistRepoForStream struct { + mu sync.Mutex + plays []string +} + +func (m *mockHistRepoForStream) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.plays = append(m.plays, trackID) + return nil +} +func (m *mockHistRepoForStream) GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) { + return nil, nil +} +func (m *mockHistRepoForStream) GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) { + return nil, nil +} +func (m *mockHistRepoForStream) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + return nil +} + +func TestStreamHandlerRecordsHistory(t *testing.T) { + tRepo := &mockStreamTrackRepo{ + track: &models.Track{ + ID: "test_track_1", + Audio: models.AudioMeta{ + Title: "Test Track", + Artist: "Test Artist", + DurationSec: 180, + FileSize: 1024, + MimeType: "audio/mpeg", + }, + }, + } + hRepo := &mockHistRepoForStream{} + + streamSvc := services.NewStreamService(tRepo, nil) + authSvc := services.NewAuthService(&config.Config{SecretKey: "test_secret"}, nil) + histSvc := services.NewHistoryService(hRepo, tRepo) + + handler := NewStreamHandler(streamSvc, authSvc, histSvc) + + token, _ := authSvc.CreateToken(&models.User{ + ID: 999, + Username: "streamuser", + }) + + r := chi.NewRouter() + handler.Routes(r) + + // 1. First stream request with ?token=... + req := httptest.NewRequest("GET", "/tracks/test_track_1/stream?token="+token, nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + // Wait up to 200ms for background goroutine to record play + time.Sleep(50 * time.Millisecond) + + hRepo.mu.Lock() + count := len(hRepo.plays) + hRepo.mu.Unlock() + + if count != 1 { + t.Errorf("expected 1 play recorded, got %d", count) + } + + // 2. Second immediate stream request within debounce window (<15s) + req2 := httptest.NewRequest("GET", "/tracks/test_track_1/stream?token="+token, nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + + time.Sleep(50 * time.Millisecond) + + hRepo.mu.Lock() + count2 := len(hRepo.plays) + hRepo.mu.Unlock() + + if count2 != 1 { + t.Errorf("expected still 1 play recorded after debounce, got %d", count2) + } +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 150110a..8370c24 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -46,7 +46,8 @@ func IsGuest(ctx context.Context) bool { return ok && g } -func extractToken(r *http.Request) string { +// ExtractToken extracts auth token from headers, query string, or cookies. +func ExtractToken(r *http.Request) string { // 1. Authorization: Bearer authHeader := strings.TrimSpace(r.Header.Get("Authorization")) if authHeader != "" { @@ -104,7 +105,7 @@ func RequireAuth(authSvc *services.AuthService) func(next http.Handler) http.Han } // 2. Check token - token := extractToken(r) + token := ExtractToken(r) if token == "" { api.RespondError(w, http.StatusUnauthorized, "Authentication required") return @@ -144,7 +145,7 @@ func OptionalAuth(authSvc *services.AuthService) func(next http.Handler) http.Ha } } - token := extractToken(r) + token := ExtractToken(r) if token != "" { if claims, err := authSvc.VerifyTokenClaims(token); err == nil { if claims.IsGuest { diff --git a/internal/models/recap.go b/internal/models/recap.go new file mode 100644 index 0000000..458ec63 --- /dev/null +++ b/internal/models/recap.go @@ -0,0 +1,191 @@ +package models + +// RecapPeriodType represents the time period of a recap. +type RecapPeriodType string + +const ( + RecapPeriodWeekly RecapPeriodType = "weekly" + RecapPeriodMonthly RecapPeriodType = "monthly" + RecapPeriodYearly RecapPeriodType = "yearly" +) + +// TrackStat represents a track's listening statistics in a recap. +type TrackStat struct { + Track *BrowseItem `json:"track"` + Plays int `json:"plays"` + Minutes float64 `json:"minutes"` +} + +// ArtistStat represents an artist's listening statistics in a recap. +type ArtistStat struct { + Name string `json:"name"` + Plays int `json:"plays"` + Minutes float64 `json:"minutes"` + CoverURL *string `json:"cover_url,omitempty"` +} + +// AlbumStat represents an album's listening statistics in a recap. +type AlbumStat struct { + Album string `json:"album"` + Artist string `json:"artist"` + AlbumID *string `json:"album_id,omitempty"` + CoverURL *string `json:"cover_url,omitempty"` + Plays int `json:"plays"` + Minutes float64 `json:"minutes"` +} + +// GenreStat represents a genre or topic's listening statistics in a recap. +type GenreStat struct { + Name string `json:"name"` + Plays int `json:"plays"` +} + +// DayStat represents listening minutes on a specific date. +type DayStat struct { + Date string `json:"date"` + Minutes int `json:"minutes"` +} + +// MonthStat represents listening minutes in a specific month. +type MonthStat struct { + Month string `json:"month"` + Minutes int `json:"minutes"` +} + +// RecapStats represents the aggregated listening statistics for a recap snapshot. +type RecapStats struct { + TotalMinutes int `json:"totalMinutes"` + TotalPlays int `json:"totalPlays"` + CompletedPlays int `json:"completedPlays"` + SkippedPlays int `json:"skippedPlays"` + UniqueTracks int `json:"uniqueTracks"` + UniqueArtists int `json:"uniqueArtists"` + UniqueAlbums int `json:"uniqueAlbums"` + TopTracks []*TrackStat `json:"topTracks"` + TopArtists []*ArtistStat `json:"topArtists"` + TopAlbums []*AlbumStat `json:"topAlbums"` + TopGenres []*GenreStat `json:"topGenres"` + ListeningByHour []int `json:"listeningByHour"` + ListeningByWeekday []int `json:"listeningByWeekday"` + ListeningByDay []*DayStat `json:"listeningByDay"` + ListeningByMonth []*MonthStat `json:"listeningByMonth"` + DiscoveryCount int `json:"discoveryCount"` + DiscoveryTracks []*BrowseItem `json:"discoveryTracks"` + RepeatCount int `json:"repeatCount"` + MostActiveHour *int `json:"mostActiveHour"` + MostActiveWeekday *int `json:"mostActiveWeekday"` + MostActiveDate *string `json:"mostActiveDate"` + MostActiveDateMinutes int `json:"mostActiveDateMinutes"` + LongestSessionMinutes int `json:"longestSessionMinutes"` + SessionCount int `json:"sessionCount"` + NightShare float64 `json:"nightShare"` + EarlyShare float64 `json:"earlyShare"` + WeekendShare float64 `json:"weekendShare"` + LosslessShare float64 `json:"losslessShare"` + TopArtistShare float64 `json:"topArtistShare"` +} + +// RecapPersonality represents a deterministic listening personality classification. +type RecapPersonality struct { + ID string `json:"id"` + Name string `json:"name"` + Detail string `json:"detail"` +} + +// RecapComparison represents comparison statistics against the preceding period. +type RecapComparison struct { + Period string `json:"period"` + TotalMinutes int `json:"totalMinutes"` + TotalPlays int `json:"totalPlays"` + UniqueArtists int `json:"uniqueArtists"` + UniqueTracks int `json:"uniqueTracks"` + MinutesDeltaPct *int `json:"minutesDeltaPct"` + PlaysDeltaPct *int `json:"playsDeltaPct"` + ArtistsDelta int `json:"artistsDelta"` + NightShareDelta int `json:"nightShareDelta"` + TopArtistPrev *string `json:"topArtistPrev"` +} + +// RecapSnapshot is the standardized snapshot schema version 1. +type RecapSnapshot struct { + SchemaVersion int `json:"schemaVersion" bson:"schemaVersion"` + Type RecapPeriodType `json:"type" bson:"type"` + Period string `json:"period" bson:"period"` + Label string `json:"label" bson:"label"` + Start float64 `json:"start" bson:"start"` + End float64 `json:"end" bson:"end"` + Ongoing bool `json:"ongoing" bson:"ongoing"` + GeneratedAt float64 `json:"generatedAt" bson:"generatedAt"` + TzOffsetMin int `json:"tzOffsetMin,omitempty" bson:"tzOffsetMin,omitempty"` + EventCount int `json:"eventCount" bson:"eventCount"` + LegacyData bool `json:"legacyData" bson:"legacyData"` + Stats *RecapStats `json:"stats" bson:"stats"` + Personality []*RecapPersonality `json:"personality" bson:"personality"` + Comparison *RecapComparison `json:"comparison" bson:"comparison"` +} + +// RecapAvailableItem describes an unlocked recap period available for a user. +type RecapAvailableItem struct { + Type RecapPeriodType `json:"type"` + Period string `json:"period"` + Label string `json:"label"` + Ongoing bool `json:"ongoing"` +} + +// RecapPublicTrack is a simplified track item for public share links. +type RecapPublicTrack struct { + Title *string `json:"title"` + Artist *string `json:"artist"` + CoverURL *string `json:"cover_url"` +} + +// RecapPublicSummary is the sanitized public representation of a shared recap. +type RecapPublicSummary struct { + Type RecapPeriodType `json:"type"` + Period string `json:"period"` + Label string `json:"label"` + TotalMinutes int `json:"totalMinutes"` + TotalPlays int `json:"totalPlays"` + UniqueArtists int `json:"uniqueArtists"` + UniqueTracks int `json:"uniqueTracks"` + TopArtist *ArtistStat `json:"topArtist"` + TopTrack *RecapPublicTrack `json:"topTrack"` + Personality []*RecapPersonality `json:"personality"` +} + +// RecapShareItem represents an item in GET /me/recaps/shares. +type RecapShareItem struct { + Token string `json:"token"` + Type RecapPeriodType `json:"type"` + Period string `json:"period"` + Label string `json:"label,omitempty"` + CreatedAt float64 `json:"created_at"` +} + +// ListeningEventDoc represents a single row stored in the listening_events collection. +type ListeningEventDoc struct { + ID string `bson:"_id"` + UserID int64 `bson:"user_id"` + TrackID string `bson:"track_id"` + PlayedAt float64 `bson:"played_at"` + StartedAt float64 `bson:"started_at"` + PlayedMs *int `bson:"played_ms"` + DurationMs int `bson:"duration_ms"` + Completed bool `bson:"completed"` + Skipped bool `bson:"skipped"` + Source string `bson:"source"` + SessionID *string `bson:"session_id,omitempty"` + CreatedAt float64 `bson:"created_at"` + Legacy bool `bson:"legacy,omitempty"` +} + +// RecapShareDoc represents a share link document in the recap_shares collection. +type RecapShareDoc struct { + Token string `bson:"token"` + UserID int64 `bson:"user_id"` + Type RecapPeriodType `bson:"type"` + Period string `bson:"period"` + CreatedAt float64 `bson:"created_at"` + RevokedAt *float64 `bson:"revoked_at,omitempty"` + Summary *RecapPublicSummary `bson:"summary"` +} diff --git a/internal/repository/history_repo.go b/internal/repository/history_repo.go index 155f914..66be267 100644 --- a/internal/repository/history_repo.go +++ b/internal/repository/history_repo.go @@ -118,12 +118,19 @@ func (r *mongoHistoryRepository) GetUserHistory(ctx context.Context, userID int6 filter := bson.M{"$or": []bson.M{ {"user_id": userID}, + {"user_id": int32(userID)}, + {"user_id": float64(userID)}, {"user_id": fmt.Sprintf("%d", userID)}, }} + scanLimit := int64(limit * 5) + if scanLimit < 250 { + scanLimit = 250 + } + opts := options.Find(). SetSort(bson.D{{Key: "played_at", Value: -1}}). - SetLimit(int64(limit * 3)) + SetLimit(scanLimit) cursor, err := r.histCol.Find(ctx, filter, opts) if err != nil { @@ -135,13 +142,15 @@ func (r *mongoHistoryRepository) GetUserHistory(ctx context.Context, userID int6 seen := make(map[string]bool) for cursor.Next(ctx) { - var doc struct { - TrackID string `bson:"track_id"` - } - if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { - if !seen[doc.TrackID] { - seen[doc.TrackID] = true - orderedIDs = append(orderedIDs, doc.TrackID) + var raw bson.M + if err := cursor.Decode(&raw); err == nil { + var tid string + if v, ok := raw["track_id"]; ok && v != nil { + tid = strings.TrimSpace(fmt.Sprint(v)) + } + if tid != "" && !seen[tid] { + seen[tid] = true + orderedIDs = append(orderedIDs, tid) if len(orderedIDs) >= limit { break } @@ -161,8 +170,15 @@ func (r *mongoHistoryRepository) GetUserTopPlayed(ctx context.Context, userID in limit = 100 } + matchFilter := bson.M{"$or": []bson.M{ + {"user_id": userID}, + {"user_id": int32(userID)}, + {"user_id": float64(userID)}, + {"user_id": fmt.Sprintf("%d", userID)}, + }} + pipeline := mongo.Pipeline{ - {{Key: "$match", Value: bson.M{"user_id": userID}}}, + {{Key: "$match", Value: matchFilter}}, {{Key: "$group", Value: bson.M{ "_id": "$track_id", "plays": bson.M{"$sum": "$plays"}, @@ -179,11 +195,15 @@ func (r *mongoHistoryRepository) GetUserTopPlayed(ctx context.Context, userID in var trackIDs []string for cursor.Next(ctx) { - var doc struct { - TrackID string `bson:"_id"` - } - if err := cursor.Decode(&doc); err == nil && doc.TrackID != "" { - trackIDs = append(trackIDs, doc.TrackID) + var raw bson.M + if err := cursor.Decode(&raw); err == nil { + var tid string + if v, ok := raw["_id"]; ok && v != nil { + tid = strings.TrimSpace(fmt.Sprint(v)) + } + if tid != "" { + trackIDs = append(trackIDs, tid) + } } } diff --git a/internal/repository/recap_repo.go b/internal/repository/recap_repo.go new file mode 100644 index 0000000..c18b8d9 --- /dev/null +++ b/internal/repository/recap_repo.go @@ -0,0 +1,589 @@ +package repository + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +const RecapSchemaVersion = 1 + +// RecapRepository defines MongoDB persistence operations for recaps and telemetry. +type RecapRepository interface { + RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) + LoadEvents(ctx context.Context, userID int64, start float64, end float64) ([]*models.ListeningEventDoc, error) + FirstEventTimestamp(ctx context.Context, userID int64) (float64, bool, error) + HasPlays(ctx context.Context, userID int64, start float64, end float64) (bool, error) + FirstSeenBefore(ctx context.Context, userID int64, trackIDs []string, before float64) (map[string]bool, error) + GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int) (*models.RecapSnapshot, float64, error) + SaveSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int, snap *models.RecapSnapshot) error + CreateShare(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, summary *models.RecapPublicSummary) (*models.RecapShareItem, error) + ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) + RevokeShare(ctx context.Context, userID int64, token string) (bool, error) + GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) + DeleteUserData(ctx context.Context, userID int64) (eventsDeleted int64, snapsDeleted int64, sharesDeleted int64, err error) +} + +type mongoRecapRepository struct { + eventsCol *mongo.Collection + historyCol *mongo.Collection + snapshotsCol *mongo.Collection + sharesCol *mongo.Collection + tracksCol *mongo.Collection + userPbCol *mongo.Collection + globPbCol *mongo.Collection +} + +// NewRecapRepository creates a new RecapRepository instance. +func NewRecapRepository(db *database.Client) RecapRepository { + return &mongoRecapRepository{ + eventsCol: db.Collection("listening_events"), + historyCol: db.Collection("userHistory"), + snapshotsCol: db.Collection("recap_snapshots"), + sharesCol: db.Collection("recap_shares"), + tracksCol: db.Collection("audioTracks"), + userPbCol: db.Collection("userPlayback"), + globPbCol: db.Collection("globalPlayback"), + } +} + +// RecordEvents saves a batch of client listening events idempotently with key user_id:event_id. +func (r *mongoRecapRepository) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) { + now := float64(time.Now().Unix()) + stored := 0 + + for _, ev := range events { + eid := strings.TrimSpace(ev.ID) + tid := strings.TrimSpace(ev.TrackID) + if eid == "" || tid == "" { + continue + } + + docID := fmt.Sprintf("%d:%s", userID, eid) + playedAt := ev.PlayedAt + if playedAt <= 0 { + playedAt = now + } + startedAt := ev.StartedAt + if startedAt <= 0 { + startedAt = playedAt + } + + playedMs := ev.PlayedMs + if playedMs < 0 { + playedMs = 0 + } + durationMs := ev.DurationMs + if durationMs < 0 { + durationMs = 0 + } + + source := strings.TrimSpace(ev.Source) + if source == "" { + source = "server" + } + if len(source) > 32 { + source = source[:32] + } + + var sessionID *string + if s := strings.TrimSpace(ev.SessionID); s != "" { + if len(s) > 64 { + s = s[:64] + } + sessionID = &s + } + + doc := bson.M{ + "_id": docID, + "user_id": userID, + "track_id": tid, + "played_at": playedAt, + "started_at": startedAt, + "played_ms": playedMs, + "duration_ms": durationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": source, + "session_id": sessionID, + "created_at": now, + } + + res, err := r.eventsCol.UpdateOne( + ctx, + bson.M{"_id": docID}, + bson.M{"$setOnInsert": doc}, + options.UpdateOne().SetUpsert(true), + ) + if err == nil && res.UpsertedCount > 0 { + stored++ + } + + // Also record in legacy playback metrics + r.recordPlaybackMetrics(ctx, userID, tid, source, playedAt) + } + + return stored, nil +} + +func (r *mongoRecapRepository) recordPlaybackMetrics(ctx context.Context, userID int64, trackID string, source string, playedAt float64) { + if trackID == "" { + return + } + now := playedAt + if now <= 0 { + now = float64(time.Now().Unix()) + } + + // userHistory log + _, _ = r.historyCol.InsertOne(ctx, bson.M{ + "user_id": userID, + "track_id": trackID, + "source": source, + "played_at": now, + }) + + // userPlayback counter + if userID > 0 { + userDocID := fmt.Sprintf("%d_%s", userID, trackID) + inc := bson.M{"count": 1} + if source != "" { + inc["sources."+source] = 1 + } + _, _ = r.userPbCol.UpdateOne( + ctx, + bson.M{"_id": userDocID}, + bson.M{ + "$setOnInsert": bson.M{ + "user_id": userID, + "track_id": trackID, + }, + "$inc": inc, + "$set": bson.M{ + "last_played_at": now, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + } + + // globalPlayback counter + incGlob := bson.M{"count": 1} + if source != "" { + incGlob["sources."+source] = 1 + } + _, _ = r.globPbCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{ + "$inc": incGlob, + "$set": bson.M{ + "last_played_at": now, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + + // Increment track play_count in tracks collection + _, _ = r.tracksCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{"$inc": bson.M{"audio.play_count": 1, "play_count": 1}}, + ) +} + +// LoadEvents retrieves listening events for a user within [start, end), falling back to userHistory if needed. +func (r *mongoRecapRepository) LoadEvents(ctx context.Context, userID int64, start float64, end float64) ([]*models.ListeningEventDoc, error) { + filter := bson.M{ + "user_id": userID, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + + cursor, err := r.eventsCol.Find(ctx, filter) + if err == nil { + defer cursor.Close(ctx) + var events []*models.ListeningEventDoc + for cursor.Next(ctx) { + var ev models.ListeningEventDoc + if err := cursor.Decode(&ev); err == nil && ev.TrackID != "" { + events = append(events, &ev) + } + } + if len(events) > 0 { + return events, nil + } + } + + // Fallback to legacy userHistory rows (where one row = one stream start) + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + + hCursor, err := r.historyCol.Find(ctx, histFilter) + if err != nil { + return nil, err + } + defer hCursor.Close(ctx) + + var legacyEvents []*models.ListeningEventDoc + for hCursor.Next(ctx) { + var row struct { + TrackID string `bson:"track_id"` + PlayedAt float64 `bson:"played_at"` + Source string `bson:"source"` + } + if err := hCursor.Decode(&row); err == nil && row.TrackID != "" { + legacyEvents = append(legacyEvents, &models.ListeningEventDoc{ + TrackID: row.TrackID, + PlayedAt: row.PlayedAt, + StartedAt: row.PlayedAt, + PlayedMs: nil, // indicates legacy play: assume full playback + DurationMs: 0, + Completed: false, + Skipped: false, + Source: row.Source, + Legacy: true, + }) + } + } + + return legacyEvents, nil +} + +// FirstEventTimestamp returns the earliest play timestamp across listening_events and userHistory. +func (r *mongoRecapRepository) FirstEventTimestamp(ctx context.Context, userID int64) (float64, bool, error) { + var earliest float64 + found := false + + // Check listening_events + evOpt := options.FindOne().SetSort(bson.D{{Key: "played_at", Value: 1}}) + var evDoc struct { + PlayedAt float64 `bson:"played_at"` + } + if err := r.eventsCol.FindOne(ctx, bson.M{"user_id": userID}, evOpt).Decode(&evDoc); err == nil && evDoc.PlayedAt > 0 { + earliest = evDoc.PlayedAt + found = true + } + + // Check userHistory + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + } + var hDoc struct { + PlayedAt float64 `bson:"played_at"` + } + if err := r.historyCol.FindOne(ctx, histFilter, evOpt).Decode(&hDoc); err == nil && hDoc.PlayedAt > 0 { + if !found || hDoc.PlayedAt < earliest { + earliest = hDoc.PlayedAt + found = true + } + } + + return earliest, found, nil +} + +// HasPlays returns whether any listening events exist for the user in [start, end). +func (r *mongoRecapRepository) HasPlays(ctx context.Context, userID int64, start float64, end float64) (bool, error) { + filter := bson.M{ + "user_id": userID, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + count, err := r.eventsCol.CountDocuments(ctx, filter, options.Count().SetLimit(1)) + if err == nil && count > 0 { + return true, nil + } + + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + hCount, err := r.historyCol.CountDocuments(ctx, histFilter, options.Count().SetLimit(1)) + if err == nil && hCount > 0 { + return true, nil + } + + return false, nil +} + +// FirstSeenBefore returns the subset of trackIDs that were listened to before the specified timestamp. +func (r *mongoRecapRepository) FirstSeenBefore(ctx context.Context, userID int64, trackIDs []string, before float64) (map[string]bool, error) { + seen := make(map[string]bool) + if len(trackIDs) == 0 { + return seen, nil + } + + evFilter := bson.M{ + "user_id": userID, + "track_id": bson.M{"$in": trackIDs}, + "played_at": bson.M{"$lt": before}, + } + evRes := r.eventsCol.Distinct(ctx, "track_id", evFilter) + if evRes.Err() == nil { + var rawValues []interface{} + if err := evRes.Decode(&rawValues); err == nil { + for _, raw := range rawValues { + if s, ok := raw.(string); ok && s != "" { + seen[s] = true + } + } + } + } + + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + "track_id": bson.M{"$in": trackIDs}, + "played_at": bson.M{"$lt": before}, + } + hRes := r.historyCol.Distinct(ctx, "track_id", histFilter) + if hRes.Err() == nil { + var rawValues []interface{} + if err := hRes.Decode(&rawValues); err == nil { + for _, raw := range rawValues { + if s, ok := raw.(string); ok && s != "" { + seen[s] = true + } + } + } + } + + return seen, nil +} + +// GetSnapshot retrieves a cached recap snapshot if available. +func (r *mongoRecapRepository) GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int) (*models.RecapSnapshot, float64, error) { + filter := bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "tz": tz, + } + + var doc struct { + SchemaVersion int `bson:"schemaVersion"` + GeneratedAt float64 `bson:"generatedAt"` + Snapshot *models.RecapSnapshot `bson:"snapshot"` + } + + err := r.snapshotsCol.FindOne(ctx, filter).Decode(&doc) + if err != nil { + return nil, 0, err + } + if doc.SchemaVersion != RecapSchemaVersion || doc.Snapshot == nil { + return nil, 0, mongo.ErrNoDocuments + } + + return doc.Snapshot, doc.GeneratedAt, nil +} + +// SaveSnapshot caches a generated recap snapshot. +func (r *mongoRecapRepository) SaveSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int, snap *models.RecapSnapshot) error { + filter := bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "tz": tz, + } + + now := float64(time.Now().Unix()) + update := bson.M{ + "$set": bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "tz": tz, + "schemaVersion": RecapSchemaVersion, + "generatedAt": now, + "snapshot": snap, + }, + } + + _, err := r.snapshotsCol.UpdateOne(ctx, filter, update, options.UpdateOne().SetUpsert(true)) + return err +} + +func generateShareToken() string { + b := make([]byte, 9) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// CreateShare creates or updates a public share token for a recap snapshot. +func (r *mongoRecapRepository) CreateShare(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, summary *models.RecapPublicSummary) (*models.RecapShareItem, error) { + existingFilter := bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "revoked_at": nil, + } + + var existing models.RecapShareDoc + if err := r.sharesCol.FindOne(ctx, existingFilter).Decode(&existing); err == nil && existing.Token != "" { + _, _ = r.sharesCol.UpdateOne(ctx, bson.M{"_id": existing.Token}, bson.M{"$set": bson.M{"summary": summary}}) + label := "" + if summary != nil { + label = summary.Label + } + return &models.RecapShareItem{ + Token: existing.Token, + Type: existing.Type, + Period: existing.Period, + Label: label, + CreatedAt: existing.CreatedAt, + }, nil + } + + token := generateShareToken() + now := float64(time.Now().Unix()) + + _, err := r.sharesCol.InsertOne(ctx, bson.M{ + "_id": token, + "token": token, + "user_id": userID, + "type": ptype, + "period": period, + "created_at": now, + "revoked_at": nil, + "summary": summary, + }) + if err != nil { + return nil, err + } + + label := "" + if summary != nil { + label = summary.Label + } + return &models.RecapShareItem{ + Token: token, + Type: ptype, + Period: period, + Label: label, + CreatedAt: now, + }, nil +} + +// ListShares lists active share links for a user. +func (r *mongoRecapRepository) ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) { + filter := bson.M{ + "user_id": userID, + "revoked_at": nil, + } + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + + cursor, err := r.sharesCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.RecapShareItem + for cursor.Next(ctx) { + var doc models.RecapShareDoc + if err := cursor.Decode(&doc); err == nil { + label := "" + if doc.Summary != nil { + label = doc.Summary.Label + } + items = append(items, &models.RecapShareItem{ + Token: doc.Token, + Type: doc.Type, + Period: doc.Period, + Label: label, + CreatedAt: doc.CreatedAt, + }) + } + } + + return items, nil +} + +// RevokeShare marks a share token as revoked. +func (r *mongoRecapRepository) RevokeShare(ctx context.Context, userID int64, token string) (bool, error) { + filter := bson.M{ + "user_id": userID, + "token": token, + "revoked_at": nil, + } + update := bson.M{ + "$set": bson.M{ + "revoked_at": float64(time.Now().Unix()), + }, + } + + res, err := r.sharesCol.UpdateOne(ctx, filter, update) + if err != nil { + return false, err + } + return res.ModifiedCount > 0, nil +} + +// GetPublicShare retrieves the public recap summary for a valid, unrevoked share token. +func (r *mongoRecapRepository) GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) { + filter := bson.M{ + "token": token, + "revoked_at": nil, + } + + var doc models.RecapShareDoc + if err := r.sharesCol.FindOne(ctx, filter).Decode(&doc); err != nil { + return nil, err + } + return doc.Summary, nil +} + +// DeleteUserData deletes all listening events, cached snapshots, and shares for a user. +func (r *mongoRecapRepository) DeleteUserData(ctx context.Context, userID int64) (int64, int64, int64, error) { + filter := bson.M{"user_id": userID} + + resE, err := r.eventsCol.DeleteMany(ctx, filter) + if err != nil { + return 0, 0, 0, err + } + resS, err := r.snapshotsCol.DeleteMany(ctx, filter) + if err != nil { + return resE.DeletedCount, 0, 0, err + } + resSh, err := r.sharesCol.DeleteMany(ctx, filter) + if err != nil { + return resE.DeletedCount, resS.DeletedCount, 0, err + } + + return resE.DeletedCount, resS.DeletedCount, resSh.DeletedCount, nil +} diff --git a/internal/server/router.go b/internal/server/router.go index 9d2cc18..3d1990a 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -102,6 +102,7 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * historyRepo := repository.NewHistoryRepository(db) dailyRepo := repository.NewDailyPlaylistRepository(db) accessRepo := repository.NewAccessControlRepository(db) + recapRepo := repository.NewRecapRepository(db) // Services trackSvc := services.NewTrackService(trackRepo) @@ -115,11 +116,12 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * dailySvc := services.NewDailyPlaylistService(dailyRepo, trackRepo) accessSvc := services.NewAccessControlService(accessRepo) lyricsSvc := services.NewLyricsEnrichmentService(cfg) + recapSvc := services.NewRecapService(recapRepo, trackRepo) // Handlers trackHandler := handlers.NewTrackHandler(trackSvc) topicHandler := handlers.NewTopicHandler(trackSvc) - streamHandler := handlers.NewStreamHandler(streamSvc) + streamHandler := handlers.NewStreamHandler(streamSvc, authSvc, histSvc) artistHandler := handlers.NewArtistHandler(artistAlbumSvc) albumHandler := handlers.NewAlbumHandler(artistAlbumSvc) favHandler := handlers.NewFavouriteHandler(favPlaylistSvc, authSvc) @@ -132,6 +134,7 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * dailyHandler := handlers.NewDailyPlaylistHandler(dailySvc, authSvc) accessHandler := handlers.NewAccessControlHandler(accessSvc, authSvc, cfg) shareHandler := handlers.NewShareHandler(trackRepo, favRepo, cfg) + recapHandler := handlers.NewRecapHandler(recapSvc, authSvc) // Register routes trackHandler.Routes(r) @@ -149,6 +152,7 @@ func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter * dailyHandler.Routes(r) accessHandler.Routes(r) shareHandler.Routes(r) + recapHandler.Routes(r) } // SPA & Static Asset Catch-All Handler diff --git a/internal/server/spa.go b/internal/server/spa.go index e7fd216..b6d6ab9 100644 --- a/internal/server/spa.go +++ b/internal/server/spa.go @@ -91,6 +91,7 @@ func isAPIOrReservedPath(path string) bool { "/daily-playlist", "/admin", "/share", + "/recaps", "/sources", "/discord", "/health", diff --git a/internal/services/enrichment_service.go b/internal/services/enrichment_service.go index 4132ea5..c872d7c 100644 --- a/internal/services/enrichment_service.go +++ b/internal/services/enrichment_service.go @@ -424,3 +424,8 @@ func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID strin logEnrich.Infof("Enriched track %s (%s - %s)", trackID, artist, title) } + +// Stop gracefully waits for in-flight enrichment workers to exit upon context cancellation. +func (s *EnrichmentService) Stop() { + s.wg.Wait() +} diff --git a/internal/services/recap_service.go b/internal/services/recap_service.go new file mode 100644 index 0000000..daf91ab --- /dev/null +++ b/internal/services/recap_service.go @@ -0,0 +1,1105 @@ +package services + +import ( + "context" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +const ( + minPlayMs = 30_000 // 30 seconds + sessionGapSec = 30 * 60 // 30 minutes + ongoingTTLSec = 15 * 60 // 15 minutes + finishedTTLSec = 24 * 3600 // 24 hours +) + +var ( + nightHours = map[int]bool{21: true, 22: true, 23: true, 0: true, 1: true, 2: true, 3: true} + earlyHours = map[int]bool{5: true, 6: true, 7: true, 8: true} +) + +// RecapService manages recap computation, caching, and sharing. +type RecapService struct { + recapRepo repository.RecapRepository + trackRepo repository.TrackRepository +} + +// NewRecapService creates a new RecapService. +func NewRecapService(recapRepo repository.RecapRepository, trackRepo repository.TrackRepository) *RecapService { + return &RecapService{ + recapRepo: recapRepo, + trackRepo: trackRepo, + } +} + +func localMidnightUTC(year, month, day int, tzOffsetMin int) float64 { + loc := time.FixedZone("user_tz", tzOffsetMin*60) + t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) + return float64(t.Unix()) +} + +func userLocalTime(epoch float64, tzOffsetMin int) time.Time { + loc := time.FixedZone("user_tz", tzOffsetMin*60) + return time.Unix(int64(epoch), 0).In(loc) +} + +// PeriodBounds calculates the start epoch, end epoch, and display label for a period ID. +func PeriodBounds(ptype models.RecapPeriodType, period string, tzOffsetMin int) (float64, float64, string, error) { + switch ptype { + case models.RecapPeriodWeekly: + parts := strings.Split(strings.ToUpper(period), "-W") + if len(parts) != 2 { + return 0, 0, "", fmt.Errorf("invalid weekly period format '%s', expected YYYY-Www", period) + } + year, err1 := strconv.Atoi(parts[0]) + week, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || week < 1 || week > 53 { + return 0, 0, "", fmt.Errorf("invalid weekly period format '%s'", period) + } + + // ISO week Monday: Jan 4 is always in week 1 + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) + isoMondayWeek1 := jan4.AddDate(0, 0, -int((jan4.Weekday()+6)%7)) + monday := isoMondayWeek1.AddDate(0, 0, (week-1)*7) + + start := localMidnightUTC(monday.Year(), int(monday.Month()), monday.Day(), tzOffsetMin) + nxtMonday := monday.AddDate(0, 0, 7) + end := localMidnightUTC(nxtMonday.Year(), int(nxtMonday.Month()), nxtMonday.Day(), tzOffsetMin) + + sunday := monday.AddDate(0, 0, 6) + label := fmt.Sprintf("%s %d – %s %d, %d", monday.Format("Jan"), monday.Day(), sunday.Format("Jan"), sunday.Day(), sunday.Year()) + return start, end, label, nil + + case models.RecapPeriodMonthly: + parts := strings.Split(period, "-") + if len(parts) != 2 { + return 0, 0, "", fmt.Errorf("invalid monthly period format '%s', expected YYYY-MM", period) + } + year, err1 := strconv.Atoi(parts[0]) + month, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || month < 1 || month > 12 { + return 0, 0, "", fmt.Errorf("invalid monthly period format '%s'", period) + } + + start := localMidnightUTC(year, month, 1, tzOffsetMin) + ny, nm := year, month+1 + if month == 12 { + ny, nm = year+1, 1 + } + end := localMidnightUTC(ny, nm, 1, tzOffsetMin) + label := fmt.Sprintf("%s %d", time.Month(month).String(), year) + return start, end, label, nil + + case models.RecapPeriodYearly: + year, err := strconv.Atoi(period) + if err != nil { + return 0, 0, "", fmt.Errorf("invalid yearly period format '%s', expected YYYY", period) + } + start := localMidnightUTC(year, 1, 1, tzOffsetMin) + end := localMidnightUTC(year+1, 1, 1, tzOffsetMin) + label := fmt.Sprintf("%d", year) + return start, end, label, nil + + default: + return 0, 0, "", fmt.Errorf("unknown period type '%s'", ptype) + } +} + +// PreviousPeriod returns the identifier of the period immediately preceding the given period. +func PreviousPeriod(ptype models.RecapPeriodType, period string) string { + switch ptype { + case models.RecapPeriodWeekly: + parts := strings.Split(strings.ToUpper(period), "-W") + if len(parts) == 2 { + year, _ := strconv.Atoi(parts[0]) + week, _ := strconv.Atoi(parts[1]) + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) + isoMondayWeek1 := jan4.AddDate(0, 0, -int((jan4.Weekday()+6)%7)) + monday := isoMondayWeek1.AddDate(0, 0, (week-1)*7).AddDate(0, 0, -7) + prevYear, prevWeek := monday.ISOWeek() + return fmt.Sprintf("%d-W%02d", prevYear, prevWeek) + } + return period + + case models.RecapPeriodMonthly: + parts := strings.Split(period, "-") + if len(parts) == 2 { + year, _ := strconv.Atoi(parts[0]) + month, _ := strconv.Atoi(parts[1]) + if month == 1 { + return fmt.Sprintf("%d-12", year-1) + } + return fmt.Sprintf("%d-%02d", year, month-1) + } + return period + + case models.RecapPeriodYearly: + year, err := strconv.Atoi(period) + if err == nil { + return strconv.Itoa(year - 1) + } + return period + + default: + return period + } +} + +// PeriodIDFor formats a timestamp into the period identifier for the given type. +func PeriodIDFor(ptype models.RecapPeriodType, t time.Time) string { + switch ptype { + case models.RecapPeriodWeekly: + year, week := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", year, week) + case models.RecapPeriodMonthly: + return t.Format("2006-01") + case models.RecapPeriodYearly: + return fmt.Sprintf("%d", t.Year()) + default: + return "" + } +} + +// IsPeriodAvailable determines whether a recap period is ready/unlocked to show to users. +func IsPeriodAvailable(ptype models.RecapPeriodType, period string, tzOffsetMin int) bool { + _, end, _, err := PeriodBounds(ptype, period, tzOffsetMin) + if err != nil { + return false + } + + now := float64(time.Now().Unix()) + if end <= now { + return true // past completed period is always available + } + + nowLocal := userLocalTime(now, tzOffsetMin) + switch ptype { + case models.RecapPeriodWeekly: + // Unlocked on weekends (Saturday or Sunday) + wd := nowLocal.Weekday() + return wd == time.Saturday || wd == time.Sunday + case models.RecapPeriodMonthly: + // Unlocked on the last day of the month + nextDay := nowLocal.AddDate(0, 0, 1) + return nextDay.Month() != nowLocal.Month() + case models.RecapPeriodYearly: + // Unlocked on Dec 31st + return nowLocal.Month() == time.December && nowLocal.Day() == 31 + default: + return false + } +} + +func classifyEvent(ev *models.ListeningEventDoc, durationMs int) (isPlay bool, completed bool, isSkip bool, playedMs int) { + if ev.PlayedMs == nil { + // Legacy history row: assume played through + if durationMs > 0 { + playedMs = durationMs + } else { + playedMs = 180_000 // 3 minutes default + } + } else { + playedMs = *ev.PlayedMs + } + + completed = ev.Completed || (durationMs > 0 && playedMs >= int(0.8*float64(durationMs))) + isPlay = playedMs >= minPlayMs || completed + isSkip = !isPlay + + effectiveMs := playedMs + if durationMs > 0 && playedMs > durationMs { + effectiveMs = durationMs + } + return isPlay, completed, isSkip, effectiveMs +} + +type startSession struct { + start float64 + ms int +} + +// ComputeStats calculates aggregated listening statistics for a list of events. +func (s *RecapService) ComputeStats( + events []*models.ListeningEventDoc, + tracks map[string]*models.Track, + tzOffsetMin int, + seenBefore map[string]bool, +) *models.RecapStats { + totalMs := 0 + plays := 0 + completed := 0 + skipped := 0 + + trackPlays := make(map[string]int) + trackMs := make(map[string]int) + + artistPlays := make(map[string]int) + artistMs := make(map[string]int) + artistCovers := make(map[string]string) + + type albumKey struct { + album string + artist string + } + albumPlays := make(map[albumKey]int) + albumMs := make(map[albumKey]int) + albumMeta := make(map[albumKey]*models.AlbumStat) + + topicPlays := make(map[string]int) + + byHour := make([]int, 24) + byWeekday := make([]int, 7) + byDate := make(map[string]int) + byMonth := make(map[string]int) + + losslessPlays := 0 + var starts []startSession + + for _, ev := range events { + t := tracks[ev.TrackID] + durationMs := ev.DurationMs + if durationMs <= 0 && t != nil && t.Audio.DurationSec > 0 { + durationMs = int(t.Audio.DurationSec) * 1000 + } + + _, isComp, isSkip, playedMs := classifyEvent(ev, durationMs) + if isSkip { + skipped++ + continue + } + + plays++ + if isComp { + completed++ + } + totalMs += playedMs + + tid := ev.TrackID + trackPlays[tid]++ + trackMs[tid] += playedMs + + when := userLocalTime(ev.PlayedAt, tzOffsetMin) + hour := when.Hour() + if hour >= 0 && hour < 24 { + byHour[hour] += playedMs + } + + // Convert Go Sunday=0 weekday to ISO Monday=0 ... Sunday=6 + isoWd := int((when.Weekday() + 6) % 7) + if isoWd >= 0 && isoWd < 7 { + byWeekday[isoWd] += playedMs + } + + dateKey := when.Format("2006-01-02") + byDate[dateKey] += playedMs + + monthKey := when.Format("2006-01") + byMonth[monthKey] += playedMs + + startTime := ev.StartedAt + if startTime <= 0 { + startTime = ev.PlayedAt + } + starts = append(starts, startSession{start: startTime, ms: playedMs}) + + if t != nil { + artist := strings.TrimSpace(t.EffectiveArtist()) + if artist == "" { + artist = "Unknown artist" + } + artistPlays[artist]++ + artistMs[artist] += playedMs + if _, exists := artistCovers[artist]; !exists { + artistCovers[artist] = t.EffectiveCoverURL() + } + + if album := strings.TrimSpace(t.Audio.Album); album != "" { + ak := albumKey{album: album, artist: artist} + albumPlays[ak]++ + albumMs[ak] += playedMs + if _, exists := albumMeta[ak]; !exists { + var albumIDPtr *string + if t.Audio.AlbumID != "" { + idCopy := t.Audio.AlbumID + albumIDPtr = &idCopy + } + var coverPtr *string + if cov := t.EffectiveCoverURL(); cov != "" { + covCopy := cov + coverPtr = &covCopy + } + albumMeta[ak] = &models.AlbumStat{ + Album: album, + Artist: artist, + AlbumID: albumIDPtr, + CoverURL: coverPtr, + } + } + } + + if t.TopicName != "" { + topicPlays[t.TopicName]++ + } + + typ := strings.ToUpper(strings.TrimSpace(t.Audio.Type)) + if typ == "FLAC" || typ == "ALAC" || typ == "WAV" { + losslessPlays++ + } + } + } + + // Longest session & session count calculation (split on gaps > 30 min) + longestSessionMs := 0 + sessionCount := 0 + if len(starts) > 0 { + sort.Slice(starts, func(i, j int) bool { + return starts[i].start < starts[j].start + }) + curStart := starts[0].start + curMs := starts[0].ms + curEnd := curStart + float64(starts[0].ms)/1000.0 + sessionCount = 1 + + for _, s := range starts[1:] { + if s.start-curEnd > sessionGapSec { + if curMs > longestSessionMs { + longestSessionMs = curMs + } + curStart = s.start + curMs = 0 + sessionCount++ + } + curMs += s.ms + end := s.start + float64(s.ms)/1000.0 + if end > curEnd { + curEnd = end + } + } + if curMs > longestSessionMs { + longestSessionMs = curMs + } + } + + // Build Top Tracks + type trackEntry struct { + tid string + plays int + ms int + } + var sortedTracks []trackEntry + for tid, count := range trackPlays { + sortedTracks = append(sortedTracks, trackEntry{tid: tid, plays: count, ms: trackMs[tid]}) + } + sort.Slice(sortedTracks, func(i, j int) bool { + if sortedTracks[i].plays == sortedTracks[j].plays { + return sortedTracks[i].ms > sortedTracks[j].ms + } + return sortedTracks[i].plays > sortedTracks[j].plays + }) + + var topTracks []*models.TrackStat + maxTopTracks := 10 + if len(sortedTracks) < maxTopTracks { + maxTopTracks = len(sortedTracks) + } + for i := 0; i < maxTopTracks; i++ { + te := sortedTracks[i] + t := tracks[te.tid] + var item *models.BrowseItem + if t != nil { + item = t.ToBrowseItem() + } else { + item = &models.BrowseItem{ + ID: te.tid, + Title: "Unknown track", + } + } + topTracks = append(topTracks, &models.TrackStat{ + Track: item, + Plays: te.plays, + Minutes: math.Round(float64(te.ms)/60000.0*10) / 10, + }) + } + + // Build Top Artists + type artistEntry struct { + name string + plays int + ms int + } + var sortedArtists []artistEntry + for name, count := range artistPlays { + sortedArtists = append(sortedArtists, artistEntry{name: name, plays: count, ms: artistMs[name]}) + } + sort.Slice(sortedArtists, func(i, j int) bool { + if sortedArtists[i].plays == sortedArtists[j].plays { + return sortedArtists[i].ms > sortedArtists[j].ms + } + return sortedArtists[i].plays > sortedArtists[j].plays + }) + + var topArtists []*models.ArtistStat + maxTopArtists := 10 + if len(sortedArtists) < maxTopArtists { + maxTopArtists = len(sortedArtists) + } + for i := 0; i < maxTopArtists; i++ { + ae := sortedArtists[i] + var covPtr *string + if c, ok := artistCovers[ae.name]; ok && c != "" { + cCopy := c + covPtr = &cCopy + } + topArtists = append(topArtists, &models.ArtistStat{ + Name: ae.name, + Plays: ae.plays, + Minutes: math.Round(float64(ae.ms)/60000.0*10) / 10, + CoverURL: covPtr, + }) + } + + // Build Top Albums + type albumEntry struct { + key albumKey + plays int + ms int + } + var sortedAlbums []albumEntry + for k, count := range albumPlays { + sortedAlbums = append(sortedAlbums, albumEntry{key: k, plays: count, ms: albumMs[k]}) + } + sort.Slice(sortedAlbums, func(i, j int) bool { + if sortedAlbums[i].plays == sortedAlbums[j].plays { + return sortedAlbums[i].ms > sortedAlbums[j].ms + } + return sortedAlbums[i].plays > sortedAlbums[j].plays + }) + + var topAlbums []*models.AlbumStat + maxTopAlbums := 6 + if len(sortedAlbums) < maxTopAlbums { + maxTopAlbums = len(sortedAlbums) + } + for i := 0; i < maxTopAlbums; i++ { + ale := sortedAlbums[i] + baseMeta := albumMeta[ale.key] + topAlbums = append(topAlbums, &models.AlbumStat{ + Album: baseMeta.Album, + Artist: baseMeta.Artist, + AlbumID: baseMeta.AlbumID, + CoverURL: baseMeta.CoverURL, + Plays: ale.plays, + Minutes: math.Round(float64(ale.ms)/60000.0*10) / 10, + }) + } + + // Top Genres + type genreEntry struct { + name string + plays int + } + var sortedGenres []genreEntry + for name, count := range topicPlays { + sortedGenres = append(sortedGenres, genreEntry{name: name, plays: count}) + } + sort.Slice(sortedGenres, func(i, j int) bool { + return sortedGenres[i].plays > sortedGenres[j].plays + }) + + var topGenres []*models.GenreStat + maxTopGenres := 5 + if len(sortedGenres) < maxTopGenres { + maxTopGenres = len(sortedGenres) + } + for i := 0; i < maxTopGenres; i++ { + topGenres = append(topGenres, &models.GenreStat{ + Name: sortedGenres[i].name, + Plays: sortedGenres[i].plays, + }) + } + + // Convert hour and weekday ms to minutes + listeningByHour := make([]int, 24) + for h := 0; h < 24; h++ { + listeningByHour[h] = int(math.Round(float64(byHour[h]) / 60000.0)) + } + listeningByWeekday := make([]int, 7) + for d := 0; d < 7; d++ { + listeningByWeekday[d] = int(math.Round(float64(byWeekday[d]) / 60000.0)) + } + + // Sorted days + var sortedDayKeys []string + for k := range byDate { + sortedDayKeys = append(sortedDayKeys, k) + } + sort.Strings(sortedDayKeys) + var listeningByDay []*models.DayStat + for _, k := range sortedDayKeys { + listeningByDay = append(listeningByDay, &models.DayStat{ + Date: k, + Minutes: int(math.Round(float64(byDate[k]) / 60000.0)), + }) + } + + // Sorted months + var sortedMonthKeys []string + for k := range byMonth { + sortedMonthKeys = append(sortedMonthKeys, k) + } + sort.Strings(sortedMonthKeys) + var listeningByMonth []*models.MonthStat + for _, k := range sortedMonthKeys { + listeningByMonth = append(listeningByMonth, &models.MonthStat{ + Month: k, + Minutes: int(math.Round(float64(byMonth[k]) / 60000.0)), + }) + } + + // Active periods & shares + nightMs := 0 + for h := range nightHours { + nightMs += byHour[h] + } + earlyMs := 0 + for h := range earlyHours { + earlyMs += byHour[h] + } + weekendMs := byWeekday[5] + byWeekday[6] + + repeatPlays := 0 + for _, c := range trackPlays { + if c > 1 { + repeatPlays += c - 1 + } + } + + var mostActiveHour *int + if totalMs > 0 { + bestH := 0 + bestHMs := byHour[0] + for h := 1; h < 24; h++ { + if byHour[h] > bestHMs { + bestH = h + bestHMs = byHour[h] + } + } + mostActiveHour = &bestH + } + + var mostActiveWeekday *int + if totalMs > 0 { + bestD := 0 + bestDMs := byWeekday[0] + for d := 1; d < 7; d++ { + if byWeekday[d] > bestDMs { + bestD = d + bestDMs = byWeekday[d] + } + } + mostActiveWeekday = &bestD + } + + var mostActiveDate *string + mostActiveDateMinutes := 0 + if len(byDate) > 0 { + bestDate := "" + bestDateMs := -1 + for d, ms := range byDate { + if ms > bestDateMs { + bestDate = d + bestDateMs = ms + } + } + if bestDate != "" { + mostActiveDate = &bestDate + mostActiveDateMinutes = int(math.Round(float64(bestDateMs) / 60000.0)) + } + } + + // Discovery tracks + var discoveryTracks []*models.BrowseItem + discoveryCount := 0 + for _, te := range sortedTracks { + if !seenBefore[te.tid] { + discoveryCount++ + if len(discoveryTracks) < 6 { + if t := tracks[te.tid]; t != nil { + discoveryTracks = append(discoveryTracks, t.ToBrowseItem()) + } + } + } + } + + round3 := func(val float64) float64 { + return math.Round(val*1000) / 1000 + } + + nightShare := 0.0 + earlyShare := 0.0 + weekendShare := 0.0 + if totalMs > 0 { + nightShare = round3(float64(nightMs) / float64(totalMs)) + earlyShare = round3(float64(earlyMs) / float64(totalMs)) + weekendShare = round3(float64(weekendMs) / float64(totalMs)) + } + + losslessShare := 0.0 + if plays > 0 { + losslessShare = round3(float64(losslessPlays) / float64(plays)) + } + + topArtistShare := 0.0 + if plays > 0 && len(sortedArtists) > 0 { + topArtistShare = round3(float64(sortedArtists[0].plays) / float64(plays)) + } + + return &models.RecapStats{ + TotalMinutes: int(math.Round(float64(totalMs) / 60000.0)), + TotalPlays: plays, + CompletedPlays: completed, + SkippedPlays: skipped, + UniqueTracks: len(trackPlays), + UniqueArtists: len(artistPlays), + UniqueAlbums: len(albumPlays), + TopTracks: topTracks, + TopArtists: topArtists, + TopAlbums: topAlbums, + TopGenres: topGenres, + ListeningByHour: listeningByHour, + ListeningByWeekday: listeningByWeekday, + ListeningByDay: listeningByDay, + ListeningByMonth: listeningByMonth, + DiscoveryCount: discoveryCount, + DiscoveryTracks: discoveryTracks, + RepeatCount: repeatPlays, + MostActiveHour: mostActiveHour, + MostActiveWeekday: mostActiveWeekday, + MostActiveDate: mostActiveDate, + MostActiveDateMinutes: mostActiveDateMinutes, + LongestSessionMinutes: int(math.Round(float64(longestSessionMs) / 60000.0)), + SessionCount: sessionCount, + NightShare: nightShare, + EarlyShare: earlyShare, + WeekendShare: weekendShare, + LosslessShare: losslessShare, + TopArtistShare: topArtistShare, + } +} + +// ComputePersonality derives up to 3 transparent listening personality traits. +func ComputePersonality(stats *models.RecapStats) []*models.RecapPersonality { + var out []*models.RecapPersonality + plays := stats.TotalPlays + if plays < 5 { + return out + } + + if stats.NightShare >= 0.45 { + out = append(out, &models.RecapPersonality{ + ID: "night_owl", + Name: "Night Owl", + Detail: fmt.Sprintf("%d%% of your listening happened after 9 PM", int(math.Round(stats.NightShare*100))), + }) + } + if stats.EarlyShare >= 0.35 { + out = append(out, &models.RecapPersonality{ + ID: "early_bird", + Name: "Early Bird", + Detail: fmt.Sprintf("%d%% of your listening was before 9 AM", int(math.Round(stats.EarlyShare*100))), + }) + } + if stats.UniqueTracks > 0 && float64(stats.DiscoveryCount)/float64(stats.UniqueTracks) >= 0.5 && stats.DiscoveryCount >= 10 { + out = append(out, &models.RecapPersonality{ + ID: "explorer", + Name: "Explorer", + Detail: fmt.Sprintf("%d of %d tracks were new to you", stats.DiscoveryCount, stats.UniqueTracks), + }) + } + if plays > 0 && float64(stats.RepeatCount)/float64(plays) >= 0.45 { + out = append(out, &models.RecapPersonality{ + ID: "replayer", + Name: "Replayer", + Detail: fmt.Sprintf("%d replays — you know what you like", stats.RepeatCount), + }) + } + if stats.TopArtistShare >= 0.4 && len(stats.TopArtists) > 0 { + out = append(out, &models.RecapPersonality{ + ID: "loyalist", + Name: "Loyalist", + Detail: fmt.Sprintf("%d%% of plays were %s", int(math.Round(stats.TopArtistShare*100)), stats.TopArtists[0].Name), + }) + } + if stats.UniqueArtists >= 25 && stats.TopArtistShare < 0.15 { + out = append(out, &models.RecapPersonality{ + ID: "genre_hopper", + Name: "Genre Hopper", + Detail: fmt.Sprintf("%d artists, no clear favourite", stats.UniqueArtists), + }) + } + if stats.UniqueAlbums > 0 && len(stats.TopAlbums) > 0 && stats.TopAlbums[0].Plays >= 8 && plays > 0 && float64(stats.CompletedPlays)/float64(plays) >= 0.7 { + out = append(out, &models.RecapPersonality{ + ID: "album_collector", + Name: "Album Listener", + Detail: "You play albums through, front to back", + }) + } + if stats.WeekendShare >= 0.5 { + out = append(out, &models.RecapPersonality{ + ID: "weekend", + Name: "Weekend Listener", + Detail: fmt.Sprintf("%d%% of listening on weekends", int(math.Round(stats.WeekendShare*100))), + }) + } + if stats.LongestSessionMinutes >= 120 { + out = append(out, &models.RecapPersonality{ + ID: "binge", + Name: "Binge Listener", + Detail: fmt.Sprintf("Longest session: %d minutes", stats.LongestSessionMinutes), + }) + } + if stats.LosslessShare >= 0.6 { + out = append(out, &models.RecapPersonality{ + ID: "audiophile", + Name: "Audiophile", + Detail: fmt.Sprintf("%d%% of plays were lossless", int(math.Round(stats.LosslessShare*100))), + }) + } + + if len(out) == 0 { + out = append(out, &models.RecapPersonality{ + ID: "steady", + Name: "Steady Listener", + Detail: fmt.Sprintf("%d plays across %d artists", plays, stats.UniqueArtists), + }) + } + + if len(out) > 3 { + return out[:3] + } + return out +} + +// BuildSnapshot generates a full RecapSnapshot for a user and period. +func (s *RecapService) BuildSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tzOffsetMin int) (*models.RecapSnapshot, error) { + start, end, label, err := PeriodBounds(ptype, period, tzOffsetMin) + if err != nil { + return nil, err + } + + events, err := s.recapRepo.LoadEvents(ctx, userID, start, end) + if err != nil { + return nil, err + } + + // Extract unique track IDs + var trackIDs []string + idSet := make(map[string]bool) + for _, e := range events { + if e.TrackID != "" && !idSet[e.TrackID] { + idSet[e.TrackID] = true + trackIDs = append(trackIDs, e.TrackID) + } + } + + // Load track metadata from MongoDB + tracksMap := make(map[string]*models.Track) + if len(trackIDs) > 0 { + loadedTracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err == nil { + for _, t := range loadedTracks { + tracksMap[t.ID] = t + } + } + } + + seenBefore, _ := s.recapRepo.FirstSeenBefore(ctx, userID, trackIDs, start) + stats := s.ComputeStats(events, tracksMap, tzOffsetMin, seenBefore) + + // Previous period comparison + prevID := PreviousPeriod(ptype, period) + var comparison *models.RecapComparison + if prevStart, prevEnd, _, err := PeriodBounds(ptype, prevID, tzOffsetMin); err == nil { + prevEvents, err := s.recapRepo.LoadEvents(ctx, userID, prevStart, prevEnd) + if err == nil && len(prevEvents) > 0 { + var prevTrackIDs []string + prevIDSet := make(map[string]bool) + for _, e := range prevEvents { + if e.TrackID != "" && !prevIDSet[e.TrackID] { + prevIDSet[e.TrackID] = true + prevTrackIDs = append(prevTrackIDs, e.TrackID) + } + } + prevTracksMap := make(map[string]*models.Track) + if loaded, err := s.trackRepo.GetByIDs(ctx, prevTrackIDs); err == nil { + for _, t := range loaded { + prevTracksMap[t.ID] = t + } + } + + prevStats := s.ComputeStats(prevEvents, prevTracksMap, tzOffsetMin, make(map[string]bool)) + comparison = ComputeComparison(prevID, stats, prevStats) + } + } + + now := float64(time.Now().Unix()) + legacyData := len(events) > 0 + for _, e := range events { + if !e.Legacy { + legacyData = false + break + } + } + + return &models.RecapSnapshot{ + SchemaVersion: repository.RecapSchemaVersion, + Type: ptype, + Period: period, + Label: label, + Start: start, + End: end, + Ongoing: end > now, + GeneratedAt: now, + TzOffsetMin: tzOffsetMin, + EventCount: len(events), + LegacyData: legacyData, + Stats: stats, + Personality: ComputePersonality(stats), + Comparison: comparison, + }, nil +} + +// GetSnapshot returns a cached snapshot if valid, or builds and saves a fresh one. +func (s *RecapService) GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tzOffsetMin int, force bool) (*models.RecapSnapshot, error) { + now := float64(time.Now().Unix()) + if !force { + cached, genAt, err := s.recapRepo.GetSnapshot(ctx, userID, ptype, period, tzOffsetMin) + if err == nil && cached != nil { + ttl := float64(finishedTTLSec) + if cached.Ongoing { + ttl = float64(ongoingTTLSec) + } + if now-genAt < ttl { + return cached, nil + } + } + } + + snap, err := s.BuildSnapshot(ctx, userID, ptype, period, tzOffsetMin) + if err != nil { + return nil, err + } + + _ = s.recapRepo.SaveSnapshot(ctx, userID, ptype, period, tzOffsetMin, snap) + return snap, nil +} + +// ListAvailable returns all unlocked periods that contain listening history for the user. +func (s *RecapService) ListAvailable(ctx context.Context, userID int64, tzOffsetMin int) ([]*models.RecapAvailableItem, error) { + firstTimestamp, found, err := s.recapRepo.FirstEventTimestamp(ctx, userID) + if err != nil || !found || firstTimestamp <= 0 { + return []*models.RecapAvailableItem{}, nil + } + + now := float64(time.Now().Unix()) + nowLocal := userLocalTime(now, tzOffsetMin) + firstLocal := userLocalTime(firstTimestamp, tzOffsetMin) + var out []*models.RecapAvailableItem + + // Check weeks (up to 8 weeks backwards) + curWeek := nowLocal + for i := 0; i < 8; i++ { + pid := PeriodIDFor(models.RecapPeriodWeekly, curWeek) + start, end, label, err := PeriodBounds(models.RecapPeriodWeekly, pid, tzOffsetMin) + if err == nil { + if end < firstTimestamp { + break + } + if IsPeriodAvailable(models.RecapPeriodWeekly, pid, tzOffsetMin) { + has, _ := s.recapRepo.HasPlays(ctx, userID, start, end) + if has { + out = append(out, &models.RecapAvailableItem{ + Type: models.RecapPeriodWeekly, + Period: pid, + Label: label, + Ongoing: end > now, + }) + } + } + } + curWeek = curWeek.AddDate(0, 0, -7) + } + + // Check months (up to 12 months backwards) + y, m := nowLocal.Year(), int(nowLocal.Month()) + for i := 0; i < 12; i++ { + pid := fmt.Sprintf("%d-%02d", y, m) + start, end, label, err := PeriodBounds(models.RecapPeriodMonthly, pid, tzOffsetMin) + if err == nil { + if end < firstTimestamp { + break + } + if IsPeriodAvailable(models.RecapPeriodMonthly, pid, tzOffsetMin) { + has, _ := s.recapRepo.HasPlays(ctx, userID, start, end) + if has { + out = append(out, &models.RecapAvailableItem{ + Type: models.RecapPeriodMonthly, + Period: pid, + Label: label, + Ongoing: end > now, + }) + } + } + } + if m == 1 { + y, m = y-1, 12 + } else { + m-- + } + } + + // Check years + for year := nowLocal.Year(); year >= firstLocal.Year(); year-- { + pid := strconv.Itoa(year) + start, end, label, err := PeriodBounds(models.RecapPeriodYearly, pid, tzOffsetMin) + if err == nil { + if IsPeriodAvailable(models.RecapPeriodYearly, pid, tzOffsetMin) { + has, _ := s.recapRepo.HasPlays(ctx, userID, start, end) + if has { + out = append(out, &models.RecapAvailableItem{ + Type: models.RecapPeriodYearly, + Period: pid, + Label: label, + Ongoing: end > now, + }) + } + } + } + } + + return out, nil +} + +// ComputeComparison calculates comparison statistics against the preceding period. +func ComputeComparison(prevID string, stats *models.RecapStats, prevStats *models.RecapStats) *models.RecapComparison { + if stats == nil || prevStats == nil { + return nil + } + + deltaPct := func(cur, old int) *int { + if old == 0 { + return nil + } + pct := int(math.Round(float64(cur-old) / float64(old) * 100)) + return &pct + } + + var topArtistPrev *string + if len(prevStats.TopArtists) > 0 { + aName := prevStats.TopArtists[0].Name + topArtistPrev = &aName + } + + return &models.RecapComparison{ + Period: prevID, + TotalMinutes: prevStats.TotalMinutes, + TotalPlays: prevStats.TotalPlays, + UniqueArtists: prevStats.UniqueArtists, + UniqueTracks: prevStats.UniqueTracks, + MinutesDeltaPct: deltaPct(stats.TotalMinutes, prevStats.TotalMinutes), + PlaysDeltaPct: deltaPct(stats.TotalPlays, prevStats.TotalPlays), + ArtistsDelta: stats.UniqueArtists - prevStats.UniqueArtists, + NightShareDelta: int(math.Round((stats.NightShare - prevStats.NightShare) * 100)), + TopArtistPrev: topArtistPrev, + } +} + +// PublicSummary converts a full RecapSnapshot into a sanitized summary for public sharing. +func PublicSummary(snap *models.RecapSnapshot) *models.RecapPublicSummary { + var topTrack *models.RecapPublicTrack + if len(snap.Stats.TopTracks) > 0 && snap.Stats.TopTracks[0].Track != nil { + t := snap.Stats.TopTracks[0].Track + var tTitle, tArtist, tCover *string + if t.Title != "" { + c := t.Title + tTitle = &c + } + if t.Artist != "" { + c := t.Artist + tArtist = &c + } + if t.CoverURL != "" { + c := t.CoverURL + tCover = &c + } + topTrack = &models.RecapPublicTrack{ + Title: tTitle, + Artist: tArtist, + CoverURL: tCover, + } + } + + var topArtist *models.ArtistStat + if len(snap.Stats.TopArtists) > 0 { + topArtist = snap.Stats.TopArtists[0] + } + + return &models.RecapPublicSummary{ + Type: snap.Type, + Period: snap.Period, + Label: snap.Label, + TotalMinutes: snap.Stats.TotalMinutes, + TotalPlays: snap.Stats.TotalPlays, + UniqueArtists: snap.Stats.UniqueArtists, + UniqueTracks: snap.Stats.UniqueTracks, + TopArtist: topArtist, + TopTrack: topTrack, + Personality: snap.Personality, + } +} + +// CreateShare creates or refreshes a public share link for a user's recap. +func (s *RecapService) CreateShare(ctx context.Context, userID int64, snap *models.RecapSnapshot) (*models.RecapShareItem, error) { + summary := PublicSummary(snap) + return s.recapRepo.CreateShare(ctx, userID, snap.Type, snap.Period, summary) +} + +// ListShares returns all active public share links for the user. +func (s *RecapService) ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) { + return s.recapRepo.ListShares(ctx, userID) +} + +// RevokeShare revokes an active share token. +func (s *RecapService) RevokeShare(ctx context.Context, userID int64, token string) (bool, error) { + return s.recapRepo.RevokeShare(ctx, userID, token) +} + +// GetPublicShare returns the sanitized summary for a public share link. +func (s *RecapService) GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) { + return s.recapRepo.GetPublicShare(ctx, token) +} + +// DeleteUserData deletes all recap telemetry and share links for a user. +func (s *RecapService) DeleteUserData(ctx context.Context, userID int64) (map[string]int64, error) { + e, sn, sh, err := s.recapRepo.DeleteUserData(ctx, userID) + if err != nil { + return nil, err + } + return map[string]int64{ + "events": e, + "snapshots": sn, + "shares": sh, + }, nil +} + +// RecordEvents saves incoming listening telemetry events. +func (s *RecapService) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) { + return s.recapRepo.RecordEvents(ctx, userID, events) +} diff --git a/internal/services/recap_service_test.go b/internal/services/recap_service_test.go new file mode 100644 index 0000000..e203f4a --- /dev/null +++ b/internal/services/recap_service_test.go @@ -0,0 +1,469 @@ +package services + +import ( + "testing" + "time" + + "streamgo/internal/models" +) + +func TestPeriodBounds(t *testing.T) { + tz := 0 // UTC + + // Weekly: 2026-W38 + start, end, label, err := PeriodBounds(models.RecapPeriodWeekly, "2026-W38", tz) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if end <= start { + t.Errorf("expected end > start, got start=%f end=%f", start, end) + } + if end-start != 7*24*3600 { + t.Errorf("expected 7 days duration in seconds (604800), got %f", end-start) + } + if label == "" { + t.Errorf("expected non-empty label") + } + + // Weekly invalid + _, _, _, err = PeriodBounds(models.RecapPeriodWeekly, "invalid-week", tz) + if err == nil { + t.Errorf("expected error on invalid weekly period") + } + + // Monthly: 2026-09 + start, end, label, err = PeriodBounds(models.RecapPeriodMonthly, "2026-09", tz) + if err != nil { + t.Fatalf("unexpected error for monthly: %v", err) + } + if label != "September 2026" { + t.Errorf("expected 'September 2026', got '%s'", label) + } + // September has 30 days = 30 * 86400 = 2592000 seconds + if end-start != 30*86400 { + t.Errorf("expected 30 days for September, got %f seconds", end-start) + } + + // Monthly rollover: 2026-12 -> next year 2027-01 + startDec, endDec, _, err := PeriodBounds(models.RecapPeriodMonthly, "2026-12", tz) + if err != nil { + t.Fatalf("unexpected error for December: %v", err) + } + // December has 31 days + if endDec-startDec != 31*86400 { + t.Errorf("expected 31 days for December, got %f seconds", endDec-startDec) + } + + // Yearly: 2026 + startY, endY, labelY, err := PeriodBounds(models.RecapPeriodYearly, "2026", tz) + if err != nil { + t.Fatalf("unexpected error for yearly: %v", err) + } + if labelY != "2026" { + t.Errorf("expected '2026', got '%s'", labelY) + } + // 2026 is non-leap year = 365 days + if endY-startY != 365*86400 { + t.Errorf("expected 365 days for 2026, got %f seconds", endY-startY) + } + + // Timezone offset test: tz = +330 (IST: UTC+5:30) + startIST, endIST, _, err := PeriodBounds(models.RecapPeriodMonthly, "2026-09", 330) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Start in UTC epoch for Sept 1 00:00 IST is Aug 31 18:30 UTC + expectedStart := float64(time.Date(2026, 8, 31, 18, 30, 0, 0, time.UTC).Unix()) + if startIST != expectedStart { + t.Errorf("expected start %f, got %f", expectedStart, startIST) + } + if endIST-startIST != 30*86400 { + t.Errorf("expected 30 days duration regardless of timezone, got %f", endIST-startIST) + } +} + +func TestPreviousPeriod(t *testing.T) { + // Weekly + prev := PreviousPeriod(models.RecapPeriodWeekly, "2026-W38") + if prev != "2026-W37" { + t.Errorf("expected 2026-W37, got %s", prev) + } + + // Monthly + prevM := PreviousPeriod(models.RecapPeriodMonthly, "2026-09") + if prevM != "2026-08" { + t.Errorf("expected 2026-08, got %s", prevM) + } + prevJan := PreviousPeriod(models.RecapPeriodMonthly, "2026-01") + if prevJan != "2025-12" { + t.Errorf("expected 2025-12, got %s", prevJan) + } + + // Yearly + prevY := PreviousPeriod(models.RecapPeriodYearly, "2026") + if prevY != "2025" { + t.Errorf("expected 2025, got %s", prevY) + } +} + +func TestPeriodIDFor(t *testing.T) { + tm := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + + wID := PeriodIDFor(models.RecapPeriodWeekly, tm) + if wID != "2026-W39" { + t.Errorf("expected 2026-W39, got %s", wID) + } + + mID := PeriodIDFor(models.RecapPeriodMonthly, tm) + if mID != "2026-09" { + t.Errorf("expected 2026-09, got %s", mID) + } + + yID := PeriodIDFor(models.RecapPeriodYearly, tm) + if yID != "2026" { + t.Errorf("expected 2026, got %s", yID) + } +} + +func TestIsPeriodAvailable(t *testing.T) { + // Past period: 2020-01 should always be available + if !IsPeriodAvailable(models.RecapPeriodMonthly, "2020-01", 0) { + t.Errorf("expected past monthly period to be available") + } + + if !IsPeriodAvailable(models.RecapPeriodWeekly, "2020-W01", 0) { + t.Errorf("expected past weekly period to be available") + } + + if !IsPeriodAvailable(models.RecapPeriodYearly, "2020", 0) { + t.Errorf("expected past yearly period to be available") + } + + // Future period (e.g. 2099-01) should NOT be available unless today is the unlock date + if IsPeriodAvailable(models.RecapPeriodMonthly, "2099-01", 0) { + t.Errorf("expected future monthly period to NOT be available") + } + + // Invalid format returns false + if IsPeriodAvailable(models.RecapPeriodWeekly, "invalid", 0) { + t.Errorf("expected invalid period to return false") + } +} + +func TestClassifyEvent(t *testing.T) { + playMs20 := 20_000 // 20s + playMs45 := 45_000 // 45s + duration := 180_000 // 3 min + + // 1. Under 30s, not completed -> skip + evSkip := &models.ListeningEventDoc{ + PlayedMs: &playMs20, + Completed: false, + DurationMs: duration, + } + isPlay, completed, isSkip, played := classifyEvent(evSkip, duration) + if isPlay || !isSkip || completed { + t.Errorf("expected skip for 20s play, got isPlay=%v, isSkip=%v, completed=%v", isPlay, isSkip, completed) + } + if played != 20_000 { + t.Errorf("expected playedMs 20000, got %d", played) + } + + // 2. Over 30s -> play + evPlay := &models.ListeningEventDoc{ + PlayedMs: &playMs45, + Completed: false, + DurationMs: duration, + } + isPlay, completed, isSkip, played = classifyEvent(evPlay, duration) + if !isPlay || isSkip || completed { + t.Errorf("expected play for 45s play, got isPlay=%v, isSkip=%v, completed=%v", isPlay, isSkip, completed) + } + if played != 45_000 { + t.Errorf("expected playedMs 45000, got %d", played) + } + + // 3. Under 30s but completed flag true -> play & completed + evCompFlag := &models.ListeningEventDoc{ + PlayedMs: &playMs20, + Completed: true, + DurationMs: duration, + } + isPlay, completed, isSkip, _ = classifyEvent(evCompFlag, duration) + if !isPlay || isSkip || !completed { + t.Errorf("expected completed play when Completed=true, got isPlay=%v, isSkip=%v, completed=%v", isPlay, isSkip, completed) + } + + // 4. Completed >= 80% duration + playMs150 := 150_000 // > 80% of 180s + ev80 := &models.ListeningEventDoc{ + PlayedMs: &playMs150, + Completed: false, + DurationMs: duration, + } + isPlay, completed, isSkip, _ = classifyEvent(ev80, duration) + if !isPlay || isSkip || !completed { + t.Errorf("expected completed play when played >= 80%% duration, got isPlay=%v, isSkip=%v, completed=%v", isPlay, isSkip, completed) + } + + // 5. Legacy event with nil PlayedMs + evLegacy := &models.ListeningEventDoc{ + PlayedMs: nil, + DurationMs: 200_000, + } + isPlay, completed, isSkip, played = classifyEvent(evLegacy, 200_000) + if !isPlay || isSkip || !completed || played != 200_000 { + t.Errorf("expected legacy event to assume full duration play, got isPlay=%v, isSkip=%v, completed=%v, played=%d", isPlay, isSkip, completed, played) + } +} + +func TestComputeStats(t *testing.T) { + svc := &RecapService{} + + playMs60 := 60_000 + playMs10 := 10_000 + + baseTime := float64(time.Date(2026, 9, 21, 22, 0, 0, 0, time.UTC).Unix()) // 22:00 = 10 PM (night hour) + + events := []*models.ListeningEventDoc{ + { + ID: "ev1", + TrackID: "t1", + PlayedAt: baseTime, + StartedAt: baseTime - 60, + PlayedMs: &playMs60, + DurationMs: 120_000, + Completed: false, + Source: "server", + }, + { + ID: "ev2", + TrackID: "t1", + PlayedAt: baseTime + 300, + StartedAt: baseTime + 240, + PlayedMs: &playMs60, + DurationMs: 120_000, + Completed: true, + Source: "flac", + }, + { + ID: "ev3", + TrackID: "t2", + PlayedAt: baseTime + 600, + StartedAt: baseTime + 590, + PlayedMs: &playMs10, // skip + DurationMs: 120_000, + Completed: false, + }, + } + + tracks := map[string]*models.Track{ + "t1": { + ID: "t1", + Audio: models.AudioMeta{ + Title: "Track One", + Artist: "Artist A", + Album: "Album A", + DurationSec: 120, + Type: "flac", + }, + }, + "t2": { + ID: "t2", + Audio: models.AudioMeta{ + Title: "Track Two", + Artist: "Artist B", + Album: "Album B", + DurationSec: 120, + }, + }, + } + + seenBefore := map[string]bool{ + "t1": true, // repeat + } + + stats := svc.ComputeStats(events, tracks, 0, seenBefore) + + if stats.TotalPlays != 2 { + t.Errorf("expected 2 plays, got %d", stats.TotalPlays) + } + if stats.SkippedPlays != 1 { + t.Errorf("expected 1 skip, got %d", stats.SkippedPlays) + } + if stats.CompletedPlays != 1 { + t.Errorf("expected 1 completed play, got %d", stats.CompletedPlays) + } + if stats.TotalMinutes != 2 { + t.Errorf("expected 2 total minutes (120s), got %d", stats.TotalMinutes) + } + if stats.UniqueTracks != 1 { + t.Errorf("expected 1 unique track played, got %d", stats.UniqueTracks) + } + if stats.UniqueArtists != 1 { + t.Errorf("expected 1 unique artist, got %d", stats.UniqueArtists) + } + if len(stats.TopTracks) != 1 || stats.TopTracks[0].Track.ID != "t1" { + t.Errorf("expected t1 as top track") + } + if len(stats.TopArtists) != 1 || stats.TopArtists[0].Name != "Artist A" { + t.Errorf("expected Artist A as top artist") + } + if stats.RepeatCount != 1 { + t.Errorf("expected 1 repeat, got %d", stats.RepeatCount) + } + // Base time is 22:00, which is in nightHours (21-03) + if stats.NightShare != 1.0 { + t.Errorf("expected nightShare 1.0, got %f", stats.NightShare) + } +} + +func TestComputePersonality(t *testing.T) { + // Night owl + statsNight := &models.RecapStats{ + NightShare: 0.55, + TotalPlays: 20, + } + traits := ComputePersonality(statsNight) + foundNight := false + for _, tr := range traits { + if tr.ID == "night_owl" { + foundNight = true + break + } + } + if !foundNight { + t.Errorf("expected night_owl personality trait") + } + + // Audiophile + statsHiFi := &models.RecapStats{ + LosslessShare: 0.65, + TotalPlays: 20, + } + traitsHiFi := ComputePersonality(statsHiFi) + foundHiFi := false + for _, tr := range traitsHiFi { + if tr.ID == "audiophile" { + foundHiFi = true + break + } + } + if !foundHiFi { + t.Errorf("expected audiophile personality trait") + } + + // Explorer + statsExplorer := &models.RecapStats{ + DiscoveryCount: 15, + UniqueTracks: 20, + TotalPlays: 30, + } + traitsExp := ComputePersonality(statsExplorer) + foundExp := false + for _, tr := range traitsExp { + if tr.ID == "explorer" { + foundExp = true + break + } + } + if !foundExp { + t.Errorf("expected explorer personality trait") + } +} + +func TestComputeComparison(t *testing.T) { + curr := &models.RecapStats{ + TotalMinutes: 120, + TotalPlays: 30, + UniqueArtists: 10, + NightShare: 0.40, + } + + prev := &models.RecapStats{ + TotalMinutes: 100, + TotalPlays: 20, + UniqueArtists: 8, + NightShare: 0.30, + TopArtists: []*models.ArtistStat{ + {Name: "Previous Top Artist"}, + }, + } + + comp := ComputeComparison("2026-08", curr, prev) + if comp == nil { + t.Fatalf("expected non-nil comparison") + } + + if comp.Period != "2026-08" { + t.Errorf("expected period 2026-08, got %s", comp.Period) + } + if comp.MinutesDeltaPct == nil || *comp.MinutesDeltaPct != 20 { + t.Errorf("expected +20%% minutes delta, got %v", comp.MinutesDeltaPct) + } + if comp.PlaysDeltaPct == nil || *comp.PlaysDeltaPct != 50 { + t.Errorf("expected +50%% plays delta, got %v", comp.PlaysDeltaPct) + } + if comp.ArtistsDelta != 2 { + t.Errorf("expected artistsDelta +2, got %d", comp.ArtistsDelta) + } + if comp.NightShareDelta != 10 { // 40% - 30% = 10% + t.Errorf("expected nightShareDelta +10, got %d", comp.NightShareDelta) + } + if comp.TopArtistPrev == nil || *comp.TopArtistPrev != "Previous Top Artist" { + t.Errorf("expected Previous Top Artist, got %v", comp.TopArtistPrev) + } +} + +func TestPublicSummary(t *testing.T) { + trackTitle := "Test Song" + artistName := "Test Artist" + snap := &models.RecapSnapshot{ + Type: models.RecapPeriodMonthly, + Period: "2026-09", + Label: "September 2026", + Stats: &models.RecapStats{ + TotalMinutes: 150, + TotalPlays: 40, + UniqueArtists: 5, + UniqueTracks: 12, + TopArtists: []*models.ArtistStat{ + {Name: artistName, Plays: 25, Minutes: 90}, + }, + TopTracks: []*models.TrackStat{ + { + Track: &models.BrowseItem{ + ID: "t1", + Title: trackTitle, + Artist: artistName, + }, + Plays: 15, + Minutes: 50, + }, + }, + }, + Personality: []*models.RecapPersonality{ + {ID: "night_owl", Name: "Night Owl", Detail: "You listen at night"}, + }, + } + + summary := PublicSummary(snap) + if summary.Type != models.RecapPeriodMonthly { + t.Errorf("expected monthly, got %s", summary.Type) + } + if summary.Period != "2026-09" { + t.Errorf("expected 2026-09, got %s", summary.Period) + } + if summary.TotalMinutes != 150 { + t.Errorf("expected 150 minutes, got %d", summary.TotalMinutes) + } + if summary.TopArtist == nil || summary.TopArtist.Name != artistName { + t.Errorf("expected top artist %s", artistName) + } + if summary.TopTrack == nil || *summary.TopTrack.Title != trackTitle { + t.Errorf("expected top track %s", trackTitle) + } + if len(summary.Personality) != 1 || summary.Personality[0].ID != "night_owl" { + t.Errorf("expected night_owl personality in public summary") + } +} diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go index 07802a9..7659d45 100644 --- a/internal/services/stream_service.go +++ b/internal/services/stream_service.go @@ -186,7 +186,40 @@ func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trac // If err != nil (malformed syntax), RFC 7233 says ignore Range and serve full content (byteRange == nil) } - // 5. Setup Response Headers + // 5. Check Telegram client connectivity BEFORE writing any response headers. + // If headers (200/206 + Content-Length) are committed and then no bytes follow, + // the browser's