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:
2026-04-25 12:40:22 +03:00
commit 0097fb5183
83 changed files with 11788 additions and 0 deletions

View 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)
}
}

View 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
}

View 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)
}
}

View 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
}

View 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)
}

View 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)
}

View 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)
}
}