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
+
+
+
+
+
+
+`,
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`,
+ 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
-
+
+
+
+
@@ -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