Files
MeowBox-Core/playlist.go
2025-12-02 17:48:54 +08:00

843 lines
24 KiB
Go
Executable File

package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
// Playlist represents a user's playlist
type Playlist struct {
Name string `json:"name"`
Songs []MusicItem `json:"songs"`
}
// PlaylistManager manages all playlists
type PlaylistManager struct {
mu sync.RWMutex
Playlists map[string]*Playlist // Legacy playlists (backward compatible)
UserPlaylists map[string][]*UserPlaylist // key: user ID, value: user's playlists
filePath string
userFilePath string
}
var playlistManager *PlaylistManager
// InitPlaylistManager initializes the playlist manager
func InitPlaylistManager() {
playlistManager = &PlaylistManager{
Playlists: make(map[string]*Playlist),
UserPlaylists: make(map[string][]*UserPlaylist),
filePath: "./files/playlists.json",
userFilePath: "./files/user_playlists.json",
}
// Create files directory if it doesn't exist
os.MkdirAll("./files", 0755)
// Load existing playlists (backward compatible)
playlistManager.loadFromFile()
// Load user playlists
playlistManager.loadUserPlaylists()
// Initialize "我喜欢" playlist if it doesn't exist (backward compatible)
if _, exists := playlistManager.Playlists["favorite"]; !exists {
playlistManager.Playlists["favorite"] = &Playlist{
Name: "我喜欢",
Songs: []MusicItem{},
}
playlistManager.saveToFile()
}
}
// loadFromFile loads playlists from JSON file
func (pm *PlaylistManager) loadFromFile() error {
pm.mu.Lock()
defer pm.mu.Unlock()
data, err := os.ReadFile(pm.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil // File doesn't exist yet, that's ok
}
return err
}
return json.Unmarshal(data, &pm.Playlists)
}
// saveToFile saves playlists to JSON file
// NOTE: Caller must hold the lock!
func (pm *PlaylistManager) saveToFile() error {
data, err := json.MarshalIndent(pm.Playlists, "", " ")
if err != nil {
return err
}
return os.WriteFile(pm.filePath, data, 0644)
}
// AddToPlaylist adds a song to a playlist
func (pm *PlaylistManager) AddToPlaylist(playlistName string, song MusicItem) error {
pm.mu.Lock()
defer pm.mu.Unlock()
playlist, exists := pm.Playlists[playlistName]
if !exists {
playlist = &Playlist{
Name: playlistName,
Songs: []MusicItem{},
}
pm.Playlists[playlistName] = playlist
}
// Check if song already exists in playlist
for _, s := range playlist.Songs {
if s.Title == song.Title && s.Artist == song.Artist {
return fmt.Errorf("song already exists in playlist")
}
}
playlist.Songs = append(playlist.Songs, song)
return pm.saveToFile()
}
// RemoveFromPlaylist removes a song from a playlist
func (pm *PlaylistManager) RemoveFromPlaylist(playlistName string, title, artist string) error {
pm.mu.Lock()
defer pm.mu.Unlock()
playlist, exists := pm.Playlists[playlistName]
if !exists {
return fmt.Errorf("playlist not found")
}
for i, song := range playlist.Songs {
if song.Title == title && song.Artist == artist {
playlist.Songs = append(playlist.Songs[:i], playlist.Songs[i+1:]...)
return pm.saveToFile()
}
}
return fmt.Errorf("song not found in playlist")
}
// GetPlaylist returns a playlist
func (pm *PlaylistManager) GetPlaylist(playlistName string) (*Playlist, error) {
pm.mu.RLock()
defer pm.mu.RUnlock()
playlist, exists := pm.Playlists[playlistName]
if !exists {
return nil, fmt.Errorf("playlist not found")
}
return playlist, nil
}
// HTTP Handlers
// HandleAddToFavorite handles adding a song to favorites (user-specific)
func HandleAddToFavorite(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[API] Add to favorite request received from %s\n", r.RemoteAddr)
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var userID string
// First, check for ESP32 device token (X-Device-Token header)
deviceToken := r.Header.Get("X-Device-Token")
if deviceToken != "" {
dm := GetDeviceManager()
device, err := dm.VerifyToken(deviceToken)
if err == nil {
userID = userStore.GetUserIDByUsername(device.Username)
fmt.Printf("[API] ESP32 device adding favorite (user: %s, userID: %s)\n", device.Username, userID)
}
}
// If not ESP32, check for web user token (Authorization header)
if userID == "" {
token := r.Header.Get("Authorization")
if token != "" {
token = strings.TrimPrefix(token, "Bearer ")
user, err := userStore.GetUserByToken(token)
if err == nil {
userID = user.ID
}
}
}
body, err := io.ReadAll(r.Body)
if err != nil {
fmt.Printf("[API] Error reading body: %v\n", err)
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
fmt.Printf("[API] Received body: %s\n", string(body))
var song MusicItem
err = json.Unmarshal(body, &song)
if err != nil {
fmt.Printf("[API] Error parsing JSON: %v\n", err)
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
fmt.Printf("[API] Adding to favorites: %s - %s (userID: %s)\n", song.Artist, song.Title, userID)
// If user is authenticated, add to user's "我喜欢" playlist
if userID != "" {
// Get user's "我喜欢" playlist (first playlist)
playlists := playlistManager.GetUserPlaylists(userID)
// If user has no playlists, initialize them first
if len(playlists) == 0 {
fmt.Printf("[API] User %s has no playlists, initializing...\n", userID)
playlistManager.InitializeUserPlaylists(userID)
playlists = playlistManager.GetUserPlaylists(userID)
}
if len(playlists) > 0 {
favoritePlaylist := playlists[0] // "我喜欢" is always the first playlist
err = playlistManager.AddSongToUserPlaylist(userID, favoritePlaylist.ID, song)
if err != nil {
if err.Error() == "song already exists in playlist" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "歌曲已在收藏列表中",
})
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// Playlist initialization failed
fmt.Printf("[Error] Failed to initialize playlists for user %s\n", userID)
http.Error(w, "Failed to initialize user playlists", http.StatusInternalServerError)
return
}
} else {
// Fallback to global favorite playlist for anonymous users
err = playlistManager.AddToPlaylist("favorite", song)
if err != nil {
if err.Error() == "song already exists in playlist" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "歌曲已在收藏列表中",
})
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
fmt.Printf("[Info] Added to favorites: %s - %s\n", song.Artist, song.Title)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "收藏成功",
})
}
// HandleRemoveFromFavorite handles removing a song from favorites (user-specific)
func HandleRemoveFromFavorite(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[API] Remove from favorite request received from %s\n", r.RemoteAddr)
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var userID string
// First, check for ESP32 device token (X-Device-Token header)
deviceToken := r.Header.Get("X-Device-Token")
if deviceToken != "" {
dm := GetDeviceManager()
device, err := dm.VerifyToken(deviceToken)
if err == nil {
userID = userStore.GetUserIDByUsername(device.Username)
fmt.Printf("[API] ESP32 device removing favorite (user: %s, userID: %s)\n", device.Username, userID)
}
}
// If not ESP32, check for web user token (Authorization header)
if userID == "" {
token := r.Header.Get("Authorization")
if token != "" {
token = strings.TrimPrefix(token, "Bearer ")
user, err := userStore.GetUserByToken(token)
if err == nil {
userID = user.ID
}
}
}
// Read song info from body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var song MusicItem
err = json.Unmarshal(body, &song)
if err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
title := song.Title
artist := song.Artist
if title == "" || artist == "" {
http.Error(w, "Missing title or artist", http.StatusBadRequest)
return
}
fmt.Printf("[API] Removing from favorites: %s - %s (userID: %s)\n", artist, title, userID)
// If user is authenticated, remove from user's "我喜欢" playlist
if userID != "" {
playlists := playlistManager.GetUserPlaylists(userID)
// If user has no playlists, nothing to remove
if len(playlists) == 0 {
fmt.Printf("[API] User %s has no playlists, nothing to remove\n", userID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "歌曲不在收藏列表中",
})
return
}
favoritePlaylist := playlists[0]
err = playlistManager.RemoveSongFromUserPlaylist(userID, favoritePlaylist.ID, title, artist)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// Fallback to global favorite playlist
err = playlistManager.RemoveFromPlaylist("favorite", title, artist)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
fmt.Printf("[Info] Removed from favorites: %s - %s\n", artist, title)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "取消收藏成功",
})
}
// HandleGetFavorites handles getting the favorite playlist (user-specific)
func HandleGetFavorites(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[API] Get favorites request received from %s\n", r.RemoteAddr)
var userID string
// First, check for ESP32 device token (X-Device-Token header)
deviceToken := r.Header.Get("X-Device-Token")
if deviceToken != "" {
dm := GetDeviceManager()
device, err := dm.VerifyToken(deviceToken)
if err == nil {
// Device.Username is the username, need to convert to userID
userID = userStore.GetUserIDByUsername(device.Username)
fmt.Printf("[API] ESP32 device authenticated: %s (user: %s, userID: %s)\n", device.MAC, device.Username, userID)
} else {
fmt.Printf("[API] Invalid device token: %v\n", err)
}
}
// If not ESP32, check for web user token (Authorization header)
if userID == "" {
token := r.Header.Get("Authorization")
if token != "" {
token = strings.TrimPrefix(token, "Bearer ")
user, err := userStore.GetUserByToken(token)
if err == nil {
userID = user.ID
fmt.Printf("[API] Web user authenticated: %s\n", userID)
}
}
}
// If user is authenticated, return user's "我喜欢" playlist
if userID != "" {
playlists := playlistManager.GetUserPlaylists(userID)
// If user has no playlists, initialize them first
if len(playlists) == 0 {
fmt.Printf("[API] User %s has no playlists, initializing...\n", userID)
playlistManager.InitializeUserPlaylists(userID)
playlists = playlistManager.GetUserPlaylists(userID)
}
if len(playlists) > 0 {
favoritePlaylist := playlists[0] // "我喜欢" is always the first playlist
fmt.Printf("[API] Returning user %s favorites: %d songs\n", userID, len(favoritePlaylist.Songs))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Return just the songs array for frontend compatibility
json.NewEncoder(w).Encode(favoritePlaylist.Songs)
return
}
// If still no playlists (initialization failed), return empty array
fmt.Printf("[API] User %s playlists initialization failed, returning empty\n", userID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode([]MusicItem{})
return
}
// Only return global favorite playlist if user is NOT authenticated
fmt.Printf("[API] No authentication found, returning global favorites\n")
playlist, err := playlistManager.GetPlaylist("favorite")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode([]MusicItem{}) // Return empty array
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(playlist.Songs)
}
// HandleCheckFavorite checks if a song is in favorites
func HandleCheckFavorite(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
artist := r.URL.Query().Get("artist")
if title == "" || artist == "" {
http.Error(w, "Missing title or artist", http.StatusBadRequest)
return
}
playlist, err := playlistManager.GetPlaylist("favorite")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"is_favorite": false})
return
}
for _, song := range playlist.Songs {
if song.Title == title && song.Artist == artist {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"is_favorite": true})
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"is_favorite": false})
}
// User Playlist Management Functions
// loadUserPlaylists loads user playlists from JSON file
func (pm *PlaylistManager) loadUserPlaylists() error {
pm.mu.Lock()
defer pm.mu.Unlock()
data, err := os.ReadFile(pm.userFilePath)
if err != nil {
if os.IsNotExist(err) {
return nil // File doesn't exist yet, that's ok
}
return err
}
return json.Unmarshal(data, &pm.UserPlaylists)
}
// saveUserPlaylists saves user playlists to JSON file
// NOTE: Caller must hold the lock!
func (pm *PlaylistManager) saveUserPlaylists() error {
data, err := json.MarshalIndent(pm.UserPlaylists, "", " ")
if err != nil {
return err
}
return os.WriteFile(pm.userFilePath, data, 0644)
}
// InitializeUserPlaylists creates default playlists for a new user
func (pm *PlaylistManager) InitializeUserPlaylists(userID string) {
pm.mu.Lock()
defer pm.mu.Unlock()
if _, exists := pm.UserPlaylists[userID]; exists {
return // Already initialized
}
// Create "我喜欢" playlist for the user
favoritePlaylist := &UserPlaylist{
ID: generateID(),
UserID: userID,
Name: "我喜欢",
Description: "我喜欢的音乐",
Songs: []MusicItem{},
IsPublic: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
pm.UserPlaylists[userID] = []*UserPlaylist{favoritePlaylist}
pm.saveUserPlaylists()
}
// CreateUserPlaylist creates a new playlist for a user
func (pm *PlaylistManager) CreateUserPlaylist(userID, name, description string) (*UserPlaylist, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
playlist := &UserPlaylist{
ID: generateID(),
UserID: userID,
Name: name,
Description: description,
Songs: []MusicItem{},
IsPublic: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
pm.UserPlaylists[userID] = append(pm.UserPlaylists[userID], playlist)
pm.saveUserPlaylists()
return playlist, nil
}
// GetUserPlaylists returns all playlists for a user
func (pm *PlaylistManager) GetUserPlaylists(userID string) []*UserPlaylist {
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.UserPlaylists[userID]
}
// GetUserPlaylistByID returns a specific playlist
func (pm *PlaylistManager) GetUserPlaylistByID(userID, playlistID string) (*UserPlaylist, error) {
pm.mu.RLock()
defer pm.mu.RUnlock()
playlists, exists := pm.UserPlaylists[userID]
if !exists {
return nil, fmt.Errorf("no playlists found for user")
}
for _, playlist := range playlists {
if playlist.ID == playlistID {
return playlist, nil
}
}
return nil, fmt.Errorf("playlist not found")
}
// AddSongToUserPlaylist adds a song to a user's playlist
func (pm *PlaylistManager) AddSongToUserPlaylist(userID, playlistID string, song MusicItem) error {
pm.mu.Lock()
defer pm.mu.Unlock()
playlists, exists := pm.UserPlaylists[userID]
if !exists {
return fmt.Errorf("no playlists found for user")
}
for _, playlist := range playlists {
if playlist.ID == playlistID {
// Check if song already exists
for _, s := range playlist.Songs {
if s.Title == song.Title && s.Artist == song.Artist {
return fmt.Errorf("song already exists in playlist")
}
}
playlist.Songs = append(playlist.Songs, song)
playlist.UpdatedAt = time.Now()
return pm.saveUserPlaylists()
}
}
return fmt.Errorf("playlist not found")
}
// RemoveSongFromUserPlaylist removes a song from a user's playlist
func (pm *PlaylistManager) RemoveSongFromUserPlaylist(userID, playlistID, title, artist string) error {
pm.mu.Lock()
defer pm.mu.Unlock()
playlists, exists := pm.UserPlaylists[userID]
if !exists {
return fmt.Errorf("no playlists found for user")
}
for _, playlist := range playlists {
if playlist.ID == playlistID {
for i, song := range playlist.Songs {
if song.Title == title && song.Artist == artist {
playlist.Songs = append(playlist.Songs[:i], playlist.Songs[i+1:]...)
playlist.UpdatedAt = time.Now()
return pm.saveUserPlaylists()
}
}
return fmt.Errorf("song not found in playlist")
}
}
return fmt.Errorf("playlist not found")
}
// DeleteUserPlaylist deletes a user's playlist
func (pm *PlaylistManager) DeleteUserPlaylist(userID, playlistID string) error {
pm.mu.Lock()
defer pm.mu.Unlock()
playlists, exists := pm.UserPlaylists[userID]
if !exists {
return fmt.Errorf("no playlists found for user")
}
for i, playlist := range playlists {
if playlist.ID == playlistID {
// Don't allow deleting "我喜欢" playlist
if playlist.Name == "我喜欢" && i == 0 {
return fmt.Errorf("cannot delete favorite playlist")
}
pm.UserPlaylists[userID] = append(playlists[:i], playlists[i+1:]...)
return pm.saveUserPlaylists()
}
}
return fmt.Errorf("playlist not found")
}
// User Playlist HTTP Handlers
// HandleCreateUserPlaylist handles creating a new playlist
func HandleCreateUserPlaylist(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, "Playlist name is required", http.StatusBadRequest)
return
}
playlist, err := playlistManager.CreateUserPlaylist(userID, req.Name, req.Description)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Printf("[Info] User %s created playlist: %s\n", userID, req.Name)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(playlist)
}
// HandleGetUserPlaylists handles getting all user playlists
func HandleGetUserPlaylists(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
playlists := playlistManager.GetUserPlaylists(userID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(playlists)
}
// HandleAddSongToUserPlaylist handles adding a song to a playlist
func HandleAddSongToUserPlaylist(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
playlistID := r.URL.Query().Get("playlist_id")
if playlistID == "" {
http.Error(w, "Missing playlist_id parameter", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var song MusicItem
if err := json.Unmarshal(body, &song); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
err = playlistManager.AddSongToUserPlaylist(userID, playlistID, song)
if err != nil {
if err.Error() == "song already exists in playlist" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "歌曲已在歌单中",
})
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Printf("[Info] User %s added song to playlist %s: %s - %s\n", userID, playlistID, song.Artist, song.Title)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "添加成功",
})
}
// HandleRemoveSongFromUserPlaylist handles removing a song from a playlist
func HandleRemoveSongFromUserPlaylist(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
playlistID := r.URL.Query().Get("playlist_id")
title := r.URL.Query().Get("title")
artist := r.URL.Query().Get("artist")
if playlistID == "" || title == "" || artist == "" {
http.Error(w, "Missing required parameters", http.StatusBadRequest)
return
}
err := playlistManager.RemoveSongFromUserPlaylist(userID, playlistID, title, artist)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Printf("[Info] User %s removed song from playlist %s: %s - %s\n", userID, playlistID, artist, title)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "移除成功",
})
}
// HandleDeleteUserPlaylist handles deleting a playlist
func HandleDeleteUserPlaylist(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
playlistID := r.URL.Query().Get("playlist_id")
if playlistID == "" {
http.Error(w, "Missing playlist_id parameter", http.StatusBadRequest)
return
}
err := playlistManager.DeleteUserPlaylist(userID, playlistID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("[Info] User %s deleted playlist %s\n", userID, playlistID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "删除成功",
})
}