Initial commit: party-mix-app with prefetch cache, audio preload optimizations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
15
apps/backend/Dockerfile
Normal file
15
apps/backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod ./
|
||||
ENV GOFLAGS=-mod=mod
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./main.go
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
14
apps/backend/go.mod
Normal file
14
apps/backend/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module github.com/toyffee/party-mix
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.37.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
37
apps/backend/internal/config/config.go
Normal file
37
apps/backend/internal/config/config.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPass string
|
||||
DBName string
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
_ = godotenv.Load()
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBUser: getEnv("DB_USER", "partymix"),
|
||||
DBPass: getEnv("DB_PASSWORD", "partymix"),
|
||||
DBName: getEnv("DB_NAME", "partymix"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
39
apps/backend/internal/database/database.go
Normal file
39
apps/backend/internal/database/database.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/toyffee/party-mix/internal/config"
|
||||
"github.com/toyffee/party-mix/internal/models"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Connect(cfg *config.Config) *gorm.DB {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
|
||||
cfg.DBHost, cfg.DBUser, cfg.DBPass, cfg.DBName, cfg.DBPort,
|
||||
)
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("database connection failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&models.Party{},
|
||||
&models.Participant{},
|
||||
&models.Track{},
|
||||
&models.HistoryItem{},
|
||||
&models.User{},
|
||||
&models.Playlist{},
|
||||
&models.PlaylistTrack{},
|
||||
&models.UserVersion{},
|
||||
); err != nil {
|
||||
log.Fatalf("database migration failed: %v", err)
|
||||
}
|
||||
log.Println("Database connected and migrated")
|
||||
return db
|
||||
}
|
||||
103
apps/backend/internal/handlers/auth.go
Normal file
103
apps/backend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toyffee/party-mix/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type registerReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
func Register(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req registerReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
ID: uuid.NewString(),
|
||||
Username: req.Username,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PasswordHash: string(hash),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username or email already taken"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", strings.ToLower(req.Email)).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateToken(user.ID, jwtSecret)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Me(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var user models.User
|
||||
if err := db.First(&user, "id = ?", userID).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
66
apps/backend/internal/handlers/middleware.go
Normal file
66
apps/backend/internal/handlers/middleware.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type jwtClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func generateToken(userID, secret string) (string, error) {
|
||||
claims := jwtClaims{
|
||||
UserID: userID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func parseToken(tokenStr, secret string) (*jwtClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &jwtClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*jwtClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func AuthRequired(jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||
if tokenStr == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
claims, err := parseToken(tokenStr, jwtSecret)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set("userID", claims.UserID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) string {
|
||||
id, _ := c.Get("userID")
|
||||
s, _ := id.(string)
|
||||
return s
|
||||
}
|
||||
173
apps/backend/internal/handlers/party.go
Normal file
173
apps/backend/internal/handlers/party.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toyffee/party-mix/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const codeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
func generateCode() string {
|
||||
b := make([]byte, 6)
|
||||
for i := range b {
|
||||
b[i] = codeChars[rand.Intn(len(codeChars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
type createPartyReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
}
|
||||
|
||||
func CreateParty(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req createPartyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
party := models.Party{
|
||||
ID: uuid.NewString(),
|
||||
Name: req.Name,
|
||||
Code: generateCode(),
|
||||
TelegramID: req.TelegramID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&party).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create party"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, party)
|
||||
}
|
||||
}
|
||||
|
||||
func GetParty(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var party models.Party
|
||||
err := db.Preload("Participants.Tracks").First(&party, "id = ?", c.Param("id")).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "party not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, party)
|
||||
}
|
||||
}
|
||||
|
||||
func GetPartyByCode(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var party models.Party
|
||||
err := db.Preload("Participants.Tracks").First(&party, "code = ?", c.Param("code")).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "party not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, party)
|
||||
}
|
||||
}
|
||||
|
||||
type addParticipantReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Tracks []string `json:"tracks" binding:"required"`
|
||||
ColorBg string `json:"color_bg"`
|
||||
ColorText string `json:"color_text"`
|
||||
}
|
||||
|
||||
func AddParticipant(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req addParticipantReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
participant := models.Participant{
|
||||
ID: uuid.NewString(),
|
||||
PartyID: c.Param("id"),
|
||||
Name: req.Name,
|
||||
ColorBg: req.ColorBg,
|
||||
ColorText: req.ColorText,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
tracks := make([]models.Track, 0, len(req.Tracks))
|
||||
for i, title := range req.Tracks {
|
||||
tracks = append(tracks, models.Track{
|
||||
ID: uuid.NewString(),
|
||||
ParticipantID: participant.ID,
|
||||
Title: title,
|
||||
Position: i,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
participant.Tracks = tracks
|
||||
if err := db.Create(&participant).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, participant)
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveParticipant(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
pid := c.Param("pid")
|
||||
db.Where("participant_id = ?", pid).Delete(&models.Track{})
|
||||
if err := db.Delete(&models.Participant{}, "id = ?", pid).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
type addHistoryReq struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerColorBg string `json:"owner_color_bg"`
|
||||
OwnerColorText string `json:"owner_color_text"`
|
||||
}
|
||||
|
||||
func AddHistory(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req addHistoryReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := models.HistoryItem{
|
||||
ID: uuid.NewString(),
|
||||
PartyID: c.Param("id"),
|
||||
Title: req.Title,
|
||||
Artist: req.Artist,
|
||||
ImgURL: req.ImgURL,
|
||||
OwnerName: req.OwnerName,
|
||||
OwnerColorBg: req.OwnerColorBg,
|
||||
OwnerColorText: req.OwnerColorText,
|
||||
PlayedAt: time.Now(),
|
||||
}
|
||||
db.Create(&item)
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
}
|
||||
|
||||
func GetHistory(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var items []models.HistoryItem
|
||||
db.Where("party_id = ?", c.Param("id")).Order("played_at desc").Limit(100).Find(&items)
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
}
|
||||
|
||||
func ClearHistory(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
db.Where("party_id = ?", c.Param("id")).Delete(&models.HistoryItem{})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
283
apps/backend/internal/handlers/playlists.go
Normal file
283
apps/backend/internal/handlers/playlists.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toyffee/party-mix/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func parseTags(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
result := []string{}
|
||||
for _, t := range strings.Split(s, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinTags(tags []string) string {
|
||||
cleaned := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
cleaned = append(cleaned, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(cleaned, ",")
|
||||
}
|
||||
|
||||
type playlistResp struct {
|
||||
models.Playlist
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type pubPlaylistResp struct {
|
||||
models.Playlist
|
||||
Tags []string `json:"tags"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func toResp(p models.Playlist) playlistResp {
|
||||
return playlistResp{Playlist: p, Tags: parseTags(p.Tags)}
|
||||
}
|
||||
|
||||
func GetPublicPlaylists(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var playlists []models.Playlist
|
||||
db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Order("position asc")
|
||||
}).Where("is_public = ?", true).Order("created_at desc").Find(&playlists)
|
||||
|
||||
type UserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
}
|
||||
userIDs := make([]string, 0, len(playlists))
|
||||
for _, p := range playlists {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
var users []UserRow
|
||||
db.Model(&models.User{}).Select("id, username").Where("id IN ?", userIDs).Find(&users)
|
||||
userMap := make(map[string]string, len(users))
|
||||
for _, u := range users {
|
||||
userMap[u.ID] = u.Username
|
||||
}
|
||||
|
||||
resp := make([]pubPlaylistResp, 0, len(playlists))
|
||||
for _, p := range playlists {
|
||||
resp = append(resp, pubPlaylistResp{
|
||||
Playlist: p,
|
||||
Tags: parseTags(p.Tags),
|
||||
Username: userMap[p.UserID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func GetPlaylists(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var playlists []models.Playlist
|
||||
db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Order("position asc")
|
||||
}).Where("user_id = ?", userID).Order("created_at desc").Find(&playlists)
|
||||
resp := make([]playlistResp, 0, len(playlists))
|
||||
for _, p := range playlists {
|
||||
resp = append(resp, toResp(p))
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
type playlistTrackInput struct {
|
||||
Title string `json:"title"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type playlistReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Tracks []playlistTrackInput `json:"tracks"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func CreatePlaylist(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req playlistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
playlist := models.Playlist{
|
||||
ID: uuid.NewString(),
|
||||
UserID: currentUserID(c),
|
||||
Name: req.Name,
|
||||
IsPublic: req.IsPublic,
|
||||
Tags: joinTags(req.Tags),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
tracks := buildTracks(playlist.ID, req.Tracks)
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&playlist).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tracks) > 0 {
|
||||
return tx.Create(&tracks).Error
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create playlist"})
|
||||
return
|
||||
}
|
||||
|
||||
playlist.Tracks = tracks
|
||||
c.JSON(http.StatusCreated, toResp(playlist))
|
||||
}
|
||||
}
|
||||
|
||||
func GetPlaylist(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var playlist models.Playlist
|
||||
if err := db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Order("position asc")
|
||||
}).First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
|
||||
return
|
||||
}
|
||||
if playlist.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResp(playlist))
|
||||
}
|
||||
}
|
||||
|
||||
func UpdatePlaylist(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var playlist models.Playlist
|
||||
if err := db.First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
|
||||
return
|
||||
}
|
||||
if playlist.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
|
||||
var req playlistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
playlist.Name = req.Name
|
||||
playlist.IsPublic = req.IsPublic
|
||||
playlist.Tags = joinTags(req.Tags)
|
||||
newTracks := buildTracks(playlist.ID, req.Tracks)
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(&playlist).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("playlist_id = ?", playlist.ID).Delete(&models.PlaylistTrack{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newTracks) > 0 {
|
||||
return tx.Create(&newTracks).Error
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not update playlist"})
|
||||
return
|
||||
}
|
||||
|
||||
playlist.Tracks = newTracks
|
||||
c.JSON(http.StatusOK, toResp(playlist))
|
||||
}
|
||||
}
|
||||
|
||||
func AddTrackToPlaylist(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var playlist models.Playlist
|
||||
if err := db.Preload("Tracks").First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
|
||||
return
|
||||
}
|
||||
if playlist.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
track := models.PlaylistTrack{
|
||||
ID: uuid.NewString(),
|
||||
PlaylistID: playlist.ID,
|
||||
Title: req.Title,
|
||||
Position: len(playlist.Tracks),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&track).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not add track"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, track)
|
||||
}
|
||||
}
|
||||
|
||||
func DeletePlaylist(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var playlist models.Playlist
|
||||
if err := db.First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
|
||||
return
|
||||
}
|
||||
if playlist.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
db.Where("playlist_id = ?", playlist.ID).Delete(&models.PlaylistTrack{})
|
||||
db.Delete(&playlist)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTracks(playlistID string, inputs []playlistTrackInput) []models.PlaylistTrack {
|
||||
tracks := make([]models.PlaylistTrack, 0, len(inputs))
|
||||
for i, t := range inputs {
|
||||
if t.Title == "" {
|
||||
continue
|
||||
}
|
||||
pos := t.Position
|
||||
if pos == 0 {
|
||||
pos = i
|
||||
}
|
||||
tracks = append(tracks, models.PlaylistTrack{
|
||||
ID: uuid.NewString(),
|
||||
PlaylistID: playlistID,
|
||||
Title: t.Title,
|
||||
Position: pos,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
432
apps/backend/internal/handlers/proxy.go
Normal file
432
apps/backend/internal/handlers/proxy.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
httpClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
// No body-read timeout: MP3 files are large and stream takes time.
|
||||
// DisableCompression=true prevents Go from auto-decompressing gzip while
|
||||
// forwarding the upstream's Content-Length (compressed size), which would
|
||||
// cause ERR_CONTENT_LENGTH_MISMATCH in the browser.
|
||||
mp3StreamClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
|
||||
dlRegex = regexp.MustCompile(`href="(/get/music/[^"]+\.mp3)"`)
|
||||
metaRegex = regexp.MustCompile(`data-musmeta='([^']+)'`)
|
||||
durRegex = regexp.MustCompile(`track__time[^>]*>([^<\s][^<]*)`)
|
||||
titleRegex = regexp.MustCompile(`track__title[^>]*>([^<]+)<`)
|
||||
artistRegex = regexp.MustCompile(`track__desc[^>]*>([^<]+)<`)
|
||||
)
|
||||
|
||||
// Search result cache
|
||||
type searchCacheEntry struct {
|
||||
results []SearchResult
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
searchCacheMu sync.RWMutex
|
||||
searchCacheMap = map[string]*searchCacheEntry{}
|
||||
)
|
||||
|
||||
func cacheKey(q string) string {
|
||||
return strings.ToLower(strings.TrimSpace(q))
|
||||
}
|
||||
|
||||
func getSearchCache(q string) ([]SearchResult, bool) {
|
||||
key := cacheKey(q)
|
||||
searchCacheMu.RLock()
|
||||
e, ok := searchCacheMap[key]
|
||||
searchCacheMu.RUnlock()
|
||||
if !ok || time.Now().After(e.expiry) {
|
||||
return nil, false
|
||||
}
|
||||
return e.results, true
|
||||
}
|
||||
|
||||
func setSearchCache(q string, results []SearchResult) {
|
||||
key := cacheKey(q)
|
||||
searchCacheMu.Lock()
|
||||
searchCacheMap[key] = &searchCacheEntry{results: results, expiry: time.Now().Add(30 * time.Minute)}
|
||||
if len(searchCacheMap) > 1000 {
|
||||
now := time.Now()
|
||||
for k, v := range searchCacheMap {
|
||||
if now.After(v.expiry) {
|
||||
delete(searchCacheMap, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
searchCacheMu.Unlock()
|
||||
}
|
||||
|
||||
type musMeta struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Img string `json:"img"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
MP3 string `json:"mp3"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Duration string `json:"duration"`
|
||||
Img string `json:"img"`
|
||||
}
|
||||
|
||||
func hitmotopHeaders(req *http.Request) {
|
||||
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")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9")
|
||||
req.Header.Set("Referer", "https://rus.hitmotop.com/")
|
||||
req.Header.Set("Origin", "https://rus.hitmotop.com")
|
||||
}
|
||||
|
||||
func TopChartsHandler(c *gin.Context) {
|
||||
targetURL := "https://rus.hitmotop.com/"
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
|
||||
return
|
||||
}
|
||||
hitmotopHeaders(req)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "fetch failed"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
|
||||
return
|
||||
}
|
||||
|
||||
results := parseSearchResults(string(body))
|
||||
if len(results) > 20 {
|
||||
results = results[:20]
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=600")
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
func SearchHandler(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
if q == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"})
|
||||
return
|
||||
}
|
||||
|
||||
if cached, ok := getSearchCache(q); ok {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := fmt.Sprintf("https://rus.hitmotop.com/search?q=%s", url.QueryEscape(q))
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
|
||||
return
|
||||
}
|
||||
hitmotopHeaders(req)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search request failed"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
|
||||
return
|
||||
}
|
||||
|
||||
results := parseSearchResults(string(body))
|
||||
setSearchCache(q, results)
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
func parseSearchResults(html string) []SearchResult {
|
||||
dlMatches := dlRegex.FindAllStringSubmatch(html, -1)
|
||||
metaMatches := metaRegex.FindAllStringSubmatch(html, -1)
|
||||
|
||||
results := make([]SearchResult, 0, len(dlMatches))
|
||||
for i, dm := range dlMatches {
|
||||
if len(dm) < 2 {
|
||||
continue
|
||||
}
|
||||
href := dm[1]
|
||||
originalMp3 := "https://rus.hitmotop.com" + href
|
||||
mp3 := "/api/proxy/mp3?url=" + url.QueryEscape(originalMp3)
|
||||
|
||||
var meta musMeta
|
||||
if i < len(metaMatches) && len(metaMatches[i]) >= 2 {
|
||||
_ = json.Unmarshal([]byte(metaMatches[i][1]), &meta)
|
||||
}
|
||||
|
||||
hrefPos := strings.Index(html, href)
|
||||
chunk := ""
|
||||
if hrefPos >= 0 {
|
||||
start := hrefPos - 800
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := hrefPos + 200
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
chunk = html[start:end]
|
||||
}
|
||||
|
||||
dur := ""
|
||||
if m := durRegex.FindStringSubmatch(chunk); len(m) >= 2 {
|
||||
dur = strings.TrimSpace(m[1])
|
||||
}
|
||||
title := meta.Title
|
||||
if title == "" {
|
||||
if m := titleRegex.FindStringSubmatch(chunk); len(m) >= 2 {
|
||||
title = strings.TrimSpace(m[1])
|
||||
}
|
||||
}
|
||||
artist := meta.Artist
|
||||
if artist == "" {
|
||||
if m := artistRegex.FindStringSubmatch(chunk); len(m) >= 2 {
|
||||
artist = strings.TrimSpace(m[1])
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, SearchResult{
|
||||
MP3: mp3,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Duration: dur,
|
||||
Img: meta.Img,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ImgProxyHandler(c *gin.Context) {
|
||||
imgURL := c.Query("url")
|
||||
if !strings.HasPrefix(imgURL, "http") {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", imgURL, nil)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
req.Header.Set("Referer", "https://rus.hitmotop.com/")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "image/jpeg"
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("Content-Type", ct)
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func MP3ProxyHandler(c *gin.Context) {
|
||||
mp3URL := c.Query("url")
|
||||
if !strings.HasPrefix(mp3URL, "https://rus.hitmotop.com/") &&
|
||||
!strings.HasPrefix(mp3URL, "https://hitmotop.com/") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only hitmotop URLs allowed"})
|
||||
return
|
||||
}
|
||||
rangeHeader := c.GetHeader("Range")
|
||||
fetchMP3(c, mp3URL, rangeHeader, 0)
|
||||
}
|
||||
|
||||
type yandexArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type yandexTrackInfo struct {
|
||||
Title string `json:"title"`
|
||||
Artists []yandexArtist `json:"artists"`
|
||||
}
|
||||
|
||||
func YandexPlaylistHandler(c *gin.Context) {
|
||||
rawURL := c.Query("url")
|
||||
if rawURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || !strings.Contains(u.Host, "yandex") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid yandex music url"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
var playlistUUID string
|
||||
if len(parts) >= 2 && parts[0] == "playlists" {
|
||||
playlistUUID = parts[1]
|
||||
}
|
||||
if playlistUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported url, use share link from Яндекс.Музыка"})
|
||||
return
|
||||
}
|
||||
|
||||
apiURL := "https://api.music.yandex.net/playlist/" + url.PathEscape(playlistUUID)
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
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")
|
||||
req.Header.Set("X-Yandex-Music-Client", "WindowsPhone/3.20")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "yandex unreachable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("yandex returned %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
Result struct {
|
||||
Title string `json:"title"`
|
||||
Tracks []struct {
|
||||
Track *yandexTrackInfo `json:"track,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artists []yandexArtist `json:"artists,omitempty"`
|
||||
} `json:"tracks"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse failed"})
|
||||
return
|
||||
}
|
||||
|
||||
tracks := make([]string, 0, len(apiResp.Result.Tracks))
|
||||
for _, t := range apiResp.Result.Tracks {
|
||||
var info *yandexTrackInfo
|
||||
if t.Track != nil {
|
||||
info = t.Track
|
||||
} else if t.Title != "" {
|
||||
info = &yandexTrackInfo{Title: t.Title, Artists: t.Artists}
|
||||
}
|
||||
if info == nil || info.Title == "" {
|
||||
continue
|
||||
}
|
||||
trackStr := info.Title
|
||||
if len(info.Artists) > 0 && info.Artists[0].Name != "" {
|
||||
trackStr = info.Artists[0].Name + " — " + info.Title
|
||||
}
|
||||
tracks = append(tracks, trackStr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"name": apiResp.Result.Title,
|
||||
"tracks": tracks,
|
||||
})
|
||||
}
|
||||
|
||||
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
|
||||
if redirectCount > 5 {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hitmotopHeaders(req)
|
||||
req.Header.Set("Accept", "audio/mpeg, audio/*, */*")
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
resp, err := mp3StreamClient.Do(req)
|
||||
if err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 307 || resp.StatusCode == 308 {
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(location, "http") {
|
||||
parsed, _ := url.Parse(targetURL)
|
||||
location = "https://" + parsed.Host + location
|
||||
}
|
||||
fetchMP3(c, location, rangeHeader, redirectCount+1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "audio/mpeg")
|
||||
for _, h := range []string{"Content-Length", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
|
||||
c.Header("Cache-Control", "public, max-age=300")
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
162
apps/backend/internal/handlers/remote.go
Normal file
162
apps/backend/internal/handlers/remote.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type RemoteQueueItem struct {
|
||||
Title string `json:"title"`
|
||||
Owner string `json:"owner"`
|
||||
ColorBg string `json:"color_bg"`
|
||||
ColorText string `json:"color_text"`
|
||||
Img string `json:"img,omitempty"`
|
||||
}
|
||||
|
||||
type RemoteVersion struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Duration string `json:"duration"`
|
||||
Img string `json:"img,omitempty"`
|
||||
}
|
||||
|
||||
type RemoteState struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Cover string `json:"cover"`
|
||||
IsPlaying bool `json:"is_playing"`
|
||||
Volume float64 `json:"volume"`
|
||||
Progress float64 `json:"progress"`
|
||||
Duration float64 `json:"duration"`
|
||||
QueueLen int `json:"queue_len"`
|
||||
CurIdx int `json:"cur_idx"`
|
||||
Queue []RemoteQueueItem `json:"queue,omitempty"`
|
||||
Versions []RemoteVersion `json:"versions,omitempty"`
|
||||
ActiveVersion int `json:"active_version"`
|
||||
}
|
||||
|
||||
type RemoteCommand struct {
|
||||
ID string `json:"id"`
|
||||
Cmd string `json:"cmd"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type remoteRoom struct {
|
||||
state RemoteState
|
||||
commands []RemoteCommand
|
||||
lastSeen time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
remoteMu sync.RWMutex
|
||||
remoteRooms = map[string]*remoteRoom{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(30 * time.Minute)
|
||||
remoteMu.Lock()
|
||||
for id, r := range remoteRooms {
|
||||
if time.Since(r.lastSeen) > 4*time.Hour {
|
||||
delete(remoteRooms, id)
|
||||
}
|
||||
}
|
||||
remoteMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func CreateRemoteRoom(c *gin.Context) {
|
||||
id := uuid.NewString()[:8]
|
||||
remoteMu.Lock()
|
||||
remoteRooms[id] = &remoteRoom{lastSeen: time.Now()}
|
||||
remoteMu.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
|
||||
func PushRemoteState(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
remoteMu.RLock()
|
||||
room, ok := remoteRooms[id]
|
||||
remoteMu.RUnlock()
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
var s RemoteState
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
room.mu.Lock()
|
||||
room.state = s
|
||||
room.lastSeen = time.Now()
|
||||
room.mu.Unlock()
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func GetRemoteState(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
remoteMu.RLock()
|
||||
room, ok := remoteRooms[id]
|
||||
remoteMu.RUnlock()
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
room.mu.Lock()
|
||||
s := room.state
|
||||
room.mu.Unlock()
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func SendRemoteCommand(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
remoteMu.RLock()
|
||||
room, ok := remoteRooms[id]
|
||||
remoteMu.RUnlock()
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
var cmd RemoteCommand
|
||||
if err := c.ShouldBindJSON(&cmd); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cmd.ID = uuid.NewString()
|
||||
room.mu.Lock()
|
||||
room.commands = append(room.commands, cmd)
|
||||
if len(room.commands) > 50 {
|
||||
room.commands = room.commands[len(room.commands)-50:]
|
||||
}
|
||||
room.mu.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func PollRemoteCommands(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
remoteMu.RLock()
|
||||
room, ok := remoteRooms[id]
|
||||
remoteMu.RUnlock()
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
room.mu.Lock()
|
||||
cmds := room.commands
|
||||
room.commands = nil
|
||||
room.lastSeen = time.Now()
|
||||
room.mu.Unlock()
|
||||
if cmds == nil {
|
||||
cmds = []RemoteCommand{}
|
||||
}
|
||||
c.JSON(http.StatusOK, cmds)
|
||||
}
|
||||
84
apps/backend/internal/handlers/versions.go
Normal file
84
apps/backend/internal/handlers/versions.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toyffee/party-mix/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func GetVersions(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var rows []models.UserVersion
|
||||
if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result := make(map[string]gin.H, len(rows))
|
||||
for _, r := range rows {
|
||||
result[r.TrackTitle] = gin.H{
|
||||
"title": r.VersionTitle,
|
||||
"artist": r.VersionArtist,
|
||||
"duration": r.VersionDuration,
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
type saveVersionReq struct {
|
||||
TrackTitle string `json:"track_title" binding:"required"`
|
||||
VersionTitle string `json:"title" binding:"required"`
|
||||
VersionArtist string `json:"artist"`
|
||||
VersionDuration string `json:"duration"`
|
||||
}
|
||||
|
||||
func SaveVersion(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var req saveVersionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var row models.UserVersion
|
||||
err := db.Where("user_id = ? AND track_title = ?", userID, req.TrackTitle).First(&row).Error
|
||||
if err != nil {
|
||||
row = models.UserVersion{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TrackTitle: req.TrackTitle,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
row.VersionTitle = req.VersionTitle
|
||||
row.VersionArtist = req.VersionArtist
|
||||
row.VersionDuration = req.VersionDuration
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
type deleteVersionReq struct {
|
||||
TrackTitle string `json:"track_title" binding:"required"`
|
||||
}
|
||||
|
||||
func DeleteVersion(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
var req deleteVersionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
db.Where("user_id = ? AND track_title = ?", userID, req.TrackTitle).Delete(&models.UserVersion{})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
79
apps/backend/internal/models/models.go
Normal file
79
apps/backend/internal/models/models.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Party struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Code string `gorm:"uniqueIndex;not null" json:"code"`
|
||||
TelegramID int64 `json:"telegram_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Participants []Participant `gorm:"foreignKey:PartyID" json:"participants,omitempty"`
|
||||
History []HistoryItem `gorm:"foreignKey:PartyID" json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
PartyID string `gorm:"not null;index" json:"party_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
ColorBg string `json:"color_bg"`
|
||||
ColorText string `json:"color_text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Tracks []Track `gorm:"foreignKey:ParticipantID" json:"tracks,omitempty"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
ParticipantID string `gorm:"not null;index" json:"participant_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type HistoryItem struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
PartyID string `gorm:"not null;index" json:"party_id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerColorBg string `json:"owner_color_bg"`
|
||||
OwnerColorText string `json:"owner_color_text"`
|
||||
PlayedAt time.Time `json:"played_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||
Tags string `gorm:"default:''" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
|
||||
}
|
||||
|
||||
type PlaylistTrack struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
PlaylistID string `gorm:"not null;index" json:"playlist_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UserVersion struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"not null;index:idx_user_version_track,unique" json:"user_id"`
|
||||
TrackTitle string `gorm:"not null;index:idx_user_version_track,unique" json:"track_title"`
|
||||
VersionTitle string `gorm:"not null" json:"version_title"`
|
||||
VersionArtist string `gorm:"not null" json:"version_artist"`
|
||||
VersionDuration string `gorm:"not null" json:"version_duration"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
84
apps/backend/internal/router/router.go
Normal file
84
apps/backend/internal/router/router.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toyffee/party-mix/internal/handlers"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func New(db *gorm.DB, jwtSecret string) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
proxy := r.Group("/api/proxy")
|
||||
{
|
||||
proxy.GET("/search", handlers.SearchHandler)
|
||||
proxy.GET("/top", handlers.TopChartsHandler)
|
||||
proxy.GET("/img", handlers.ImgProxyHandler)
|
||||
proxy.GET("/mp3", handlers.MP3ProxyHandler)
|
||||
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
|
||||
}
|
||||
|
||||
auth := r.Group("/api/auth")
|
||||
{
|
||||
auth.POST("/register", handlers.Register(db))
|
||||
auth.POST("/login", handlers.Login(db, jwtSecret))
|
||||
auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db))
|
||||
}
|
||||
|
||||
r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db))
|
||||
|
||||
versions := r.Group("/api/versions", handlers.AuthRequired(jwtSecret))
|
||||
{
|
||||
versions.GET("", handlers.GetVersions(db))
|
||||
versions.POST("", handlers.SaveVersion(db))
|
||||
versions.DELETE("", handlers.DeleteVersion(db))
|
||||
}
|
||||
|
||||
playlists := r.Group("/api/playlists", handlers.AuthRequired(jwtSecret))
|
||||
{
|
||||
playlists.GET("", handlers.GetPlaylists(db))
|
||||
playlists.POST("", handlers.CreatePlaylist(db))
|
||||
playlists.GET("/:id", handlers.GetPlaylist(db))
|
||||
playlists.PUT("/:id", handlers.UpdatePlaylist(db))
|
||||
playlists.DELETE("/:id", handlers.DeletePlaylist(db))
|
||||
playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db))
|
||||
}
|
||||
|
||||
remote := r.Group("/api/remote")
|
||||
{
|
||||
remote.POST("", handlers.CreateRemoteRoom)
|
||||
remote.PUT("/:id/state", handlers.PushRemoteState)
|
||||
remote.GET("/:id/state", handlers.GetRemoteState)
|
||||
remote.POST("/:id/command", handlers.SendRemoteCommand)
|
||||
remote.GET("/:id/commands", handlers.PollRemoteCommands)
|
||||
}
|
||||
|
||||
parties := r.Group("/api/parties")
|
||||
{
|
||||
parties.POST("", handlers.CreateParty(db))
|
||||
parties.GET("/:id", handlers.GetParty(db))
|
||||
parties.GET("/code/:code", handlers.GetPartyByCode(db))
|
||||
parties.POST("/:id/participants", handlers.AddParticipant(db))
|
||||
parties.DELETE("/:id/participants/:pid", handlers.RemoveParticipant(db))
|
||||
parties.POST("/:id/history", handlers.AddHistory(db))
|
||||
parties.GET("/:id/history", handlers.GetHistory(db))
|
||||
parties.DELETE("/:id/history", handlers.ClearHistory(db))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
19
apps/backend/main.go
Normal file
19
apps/backend/main.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/toyffee/party-mix/internal/config"
|
||||
"github.com/toyffee/party-mix/internal/database"
|
||||
"github.com/toyffee/party-mix/internal/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
db := database.Connect(cfg)
|
||||
r := router.New(db, cfg.JWTSecret)
|
||||
log.Printf("Party Mix backend starting on :%s", cfg.Port)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("server failed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user