playlist creation, viewing, integration with other views. still wip for full playlist functionality
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"yellowjacket/backend/frontendutil"
|
||||
"yellowjacket/backend/library"
|
||||
"yellowjacket/backend/player"
|
||||
"yellowjacket/backend/playlist"
|
||||
"yellowjacket/backend/queue"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,7 @@ type YellowJacketApp struct {
|
||||
database *database.DB
|
||||
library *library.Library
|
||||
player *player.Player
|
||||
playlist *playlist.Service
|
||||
queue *queue.Queue
|
||||
appContext context.Context
|
||||
appConfig *config.Config
|
||||
@@ -93,9 +95,13 @@ func NewYellowJacketApp(
|
||||
|
||||
yjApp.assetHandler.RegisterHandler("/covers/", coverHandler)
|
||||
|
||||
// create playlist service
|
||||
yjApp.playlist = playlist.NewService(yjApp.logger, yjApp.database)
|
||||
|
||||
yjApp.FEBindings = []any{
|
||||
yjApp.FrontendUtil,
|
||||
yjApp.library,
|
||||
yjApp.playlist,
|
||||
}
|
||||
|
||||
return yjApp, nil
|
||||
@@ -113,6 +119,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.appConfig.SetContext(ctx)
|
||||
yj.FrontendUtil.SetContext(ctx)
|
||||
yj.library.SetContext(ctx)
|
||||
yj.playlist.SetContext(ctx)
|
||||
|
||||
var err error
|
||||
// create player
|
||||
|
||||
@@ -30,3 +30,7 @@ DELETE FROM playlist_tracks WHERE id = ?;
|
||||
|
||||
-- name: ClearPlaylistTracks :exec
|
||||
DELETE FROM playlist_tracks WHERE playlist_id = ?;
|
||||
|
||||
-- name: GetNextPlaylistTrackPosition :one
|
||||
SELECT COALESCE(MAX(position), -1) + 1 AS next_position
|
||||
FROM playlist_tracks WHERE playlist_id = ?;
|
||||
|
||||
@@ -99,6 +99,18 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNextPlaylistTrackPosition = `-- name: GetNextPlaylistTrackPosition :one
|
||||
SELECT COALESCE(MAX(position), -1) + 1 AS next_position
|
||||
FROM playlist_tracks WHERE playlist_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID int64) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, getNextPlaylistTrackPosition, playlistID)
|
||||
var next_position int64
|
||||
err := row.Scan(&next_position)
|
||||
return next_position, err
|
||||
}
|
||||
|
||||
const getPlaylist = `-- name: GetPlaylist :one
|
||||
SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Package playlist provides playlist management functionality.
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyName = errors.New("playlist name cannot be empty")
|
||||
errEmptyFilePath = errors.New("file path cannot be empty")
|
||||
errNoFilePaths = errors.New("no file paths provided")
|
||||
)
|
||||
|
||||
// Summary is a lightweight representation of a playlist for the picker UI.
|
||||
type Summary struct {
|
||||
ID int64 `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
// Service manages playlist operations.
|
||||
type Service struct {
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewService creates a new playlist service.
|
||||
func NewService(
|
||||
logger *slog.Logger,
|
||||
db *database.DB,
|
||||
) *Service {
|
||||
return &Service{
|
||||
logger: logger.WithGroup("playlist"),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext sets the Wails runtime context.
|
||||
func (s *Service) SetContext(ctx context.Context) {
|
||||
s.ctx = ctx
|
||||
}
|
||||
|
||||
// GetAllPlaylists returns all playlists ordered by most recently updated.
|
||||
func (s *Service) GetAllPlaylists() ([]Summary, error) {
|
||||
playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get playlists", "err", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to get playlists: %w", err)
|
||||
}
|
||||
|
||||
summaries := make([]Summary, 0, len(playlists))
|
||||
for _, p := range playlists {
|
||||
summaries = append(summaries, Summary{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// CreatePlaylist creates a new empty playlist with the given name.
|
||||
func (s *Service) CreatePlaylist(name string) (Summary, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return Summary{}, errEmptyName
|
||||
}
|
||||
|
||||
created, err := s.db.Queries.CreatePlaylist(s.db.Ctx, trimmed)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create playlist", "name", trimmed, "err", err)
|
||||
|
||||
return Summary{}, fmt.Errorf("failed to create playlist: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Playlist created", "id", created.ID, "name", created.Name)
|
||||
|
||||
return Summary{ID: created.ID, Name: created.Name}, nil
|
||||
}
|
||||
|
||||
// AddTracksToPlaylist adds one or more tracks to an existing playlist.
|
||||
func (s *Service) AddTracksToPlaylist(
|
||||
playlistID int64,
|
||||
filePaths []string,
|
||||
) error {
|
||||
if len(filePaths) == 0 {
|
||||
return errNoFilePaths
|
||||
}
|
||||
|
||||
nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
|
||||
s.db.Ctx,
|
||||
playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to get next position",
|
||||
"playlistId", playlistID,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
return fmt.Errorf("failed to get next track position: %w", err)
|
||||
}
|
||||
|
||||
for i, fp := range filePaths {
|
||||
if err := s.addSingleTrack(playlistID, fp, nextPos+int64(i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Tracks added to playlist",
|
||||
"playlistId", playlistID,
|
||||
"count", len(filePaths),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePlaylistWithTracks creates a new playlist and populates it with tracks.
|
||||
func (s *Service) CreatePlaylistWithTracks(
|
||||
name string,
|
||||
filePaths []string,
|
||||
) (Summary, error) {
|
||||
summary, err := s.CreatePlaylist(name)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
|
||||
if len(filePaths) > 0 {
|
||||
if err := s.AddTracksToPlaylist(summary.ID, filePaths); err != nil {
|
||||
return Summary{}, fmt.Errorf(
|
||||
"playlist created but failed to add tracks: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// addSingleTrack looks up the audio file by path and inserts it into the playlist.
|
||||
func (s *Service) addSingleTrack(
|
||||
playlistID int64,
|
||||
filePath string,
|
||||
position int64,
|
||||
) error {
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return errEmptyFilePath
|
||||
}
|
||||
|
||||
audioFile, err := s.db.Queries.GetAudioFileByPath(s.db.Ctx, filePath)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to find audio file",
|
||||
"filePath", filePath,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
return fmt.Errorf("failed to find audio file %q: %w", filePath, err)
|
||||
}
|
||||
|
||||
_, err = s.db.Queries.AddPlaylistTrack(
|
||||
s.db.Ctx,
|
||||
sqlcgen.AddPlaylistTrackParams{
|
||||
PlaylistID: playlistID,
|
||||
AudioFileID: audioFile.ID,
|
||||
Position: position,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to add track to playlist",
|
||||
"playlistId", playlistID,
|
||||
"audioFileId", audioFile.ID,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
return fmt.Errorf("failed to add track to playlist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user