added "default playlist"/ favorites system

This commit is contained in:
2026-02-25 22:53:57 -05:00
parent 51da5cfb07
commit d01f5e5d14
23 changed files with 1547 additions and 22 deletions
+2
View File
@@ -103,6 +103,7 @@ func NewYellowJacketApp(
yjApp.playlist = playlist.NewService(
yjApp.logger, yjApp.database, yjApp.appConfig,
)
yjApp.playlist.SetFavoritesConfig(yjApp.appConfig)
// create queue (before wails.Run so it can be bound)
yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)
@@ -145,6 +146,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.FrontendUtil.SetContext(ctx)
yj.library.SetContext(ctx)
yj.playlist.SetContext(ctx)
yj.playlist.EnsureDefaultPlaylist()
// Initialize speaker hardware (player struct created in
// NewYellowJacketApp for Wails binding registration).
+107
View File
@@ -13,6 +13,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
"yellowjacket/backend/favorites"
"yellowjacket/backend/library"
"yellowjacket/backend/system"
"yellowjacket/backend/theme"
@@ -28,6 +29,7 @@ type Config struct {
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
}
// NewConfig creates a new config by loading it from disk.
@@ -78,6 +80,12 @@ func (c *Config) Validate() error {
}
}
if c.Favorites != nil {
if err := c.Favorites.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if configErrs != nil {
return fmt.Errorf(
"one or more config parts are invalid: %w",
@@ -174,6 +182,12 @@ func (c *Config) applyDefaults() {
}
c.TrackList.ApplyDefaults()
if c.Favorites == nil {
c.Favorites = &favorites.Config{}
}
c.Favorites.ApplyDefaults()
}
// SetContext sets the Wails runtime context for event emission.
@@ -436,3 +450,96 @@ func (c *Config) emitTrackListChanged() {
},
)
}
// GetFavoritesPlaylistID returns the configured default playlist ID.
func (c *Config) GetFavoritesPlaylistID() int64 {
if c.Favorites == nil {
return 0
}
return c.Favorites.PlaylistID
}
// SetFavoritesPlaylistID saves a new default playlist ID.
func (c *Config) SetFavoritesPlaylistID(id int64) error {
if c.Favorites == nil {
c.Favorites = &favorites.Config{}
c.Favorites.ApplyDefaults()
}
c.Favorites.PlaylistID = id
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitFavoritesChanged()
c.logger.Info(
"favorites playlist ID updated",
"playlistId", id,
)
return nil
}
// GetFavoritesIconStyle returns the configured icon style.
func (c *Config) GetFavoritesIconStyle() string {
if c.Favorites == nil {
return string(favorites.DefaultIconStyle)
}
return string(c.Favorites.IconStyle)
}
// SetFavoritesIconStyle validates and saves a new icon style.
func (c *Config) SetFavoritesIconStyle(
style string,
) error {
if c.Favorites == nil {
c.Favorites = &favorites.Config{}
c.Favorites.ApplyDefaults()
}
c.Favorites.IconStyle = favorites.IconStyle(style)
if err := c.Favorites.Validate(); err != nil {
return fmt.Errorf(
"invalid favorites icon style: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitFavoritesChanged()
c.logger.Info(
"favorites icon style updated",
"style", style,
)
return nil
}
// emitFavoritesChanged sends the FavoritesConfigChanged event
// to the frontend.
func (c *Config) emitFavoritesChanged() {
if c.ctx == nil || c.Favorites == nil {
return
}
runtime.EventsEmit(
c.ctx,
events.FavoritesConfigChanged,
map[string]any{
"PlaylistID": c.Favorites.PlaylistID,
"IconStyle": string(c.Favorites.IconStyle),
},
)
}
@@ -88,3 +88,23 @@ DELETE FROM playlist_tracks;
-- name: GetNextPlaylistTrackPosition :one
SELECT COALESCE(MAX(position), -1) + 1 AS next_position
FROM playlist_tracks WHERE playlist_id = ?;
-- name: GetPlaylistTrackFilePaths :many
SELECT af.file_path
FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
WHERE pt.playlist_id = ?
ORDER BY pt.position;
-- name: IsTrackInPlaylist :one
SELECT EXISTS(
SELECT 1 FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
WHERE pt.playlist_id = ? AND af.file_path = ?
) AS in_playlist;
-- name: RemovePlaylistTrackByPath :exec
DELETE FROM playlist_tracks
WHERE playlist_id = ? AND audio_file_id = (
SELECT id FROM audio_files WHERE file_path = ?
);
@@ -209,6 +209,37 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
return i, err
}
const getPlaylistTrackFilePaths = `-- name: GetPlaylistTrackFilePaths :many
SELECT af.file_path
FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
WHERE pt.playlist_id = ?
ORDER BY pt.position
`
func (q *Queries) GetPlaylistTrackFilePaths(ctx context.Context, playlistID int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getPlaylistTrackFilePaths, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var file_path string
if err := rows.Scan(&file_path); err != nil {
return nil, err
}
items = append(items, file_path)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getPlaylistTracks = `-- name: GetPlaylistTracks :many
SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position, af.file_path
FROM playlist_tracks pt
@@ -328,6 +359,26 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID
return items, nil
}
const isTrackInPlaylist = `-- name: IsTrackInPlaylist :one
SELECT EXISTS(
SELECT 1 FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
WHERE pt.playlist_id = ? AND af.file_path = ?
) AS in_playlist
`
type IsTrackInPlaylistParams struct {
PlaylistID int64
FilePath string
}
func (q *Queries) IsTrackInPlaylist(ctx context.Context, arg IsTrackInPlaylistParams) (int64, error) {
row := q.db.QueryRowContext(ctx, isTrackInPlaylist, arg.PlaylistID, arg.FilePath)
var in_playlist int64
err := row.Scan(&in_playlist)
return in_playlist, err
}
const removePlaylistTrack = `-- name: RemovePlaylistTrack :exec
DELETE FROM playlist_tracks WHERE id = ?
`
@@ -337,6 +388,23 @@ func (q *Queries) RemovePlaylistTrack(ctx context.Context, id int64) error {
return err
}
const removePlaylistTrackByPath = `-- name: RemovePlaylistTrackByPath :exec
DELETE FROM playlist_tracks
WHERE playlist_id = ? AND audio_file_id = (
SELECT id FROM audio_files WHERE file_path = ?
)
`
type RemovePlaylistTrackByPathParams struct {
PlaylistID int64
FilePath string
}
func (q *Queries) RemovePlaylistTrackByPath(ctx context.Context, arg RemovePlaylistTrackByPathParams) error {
_, err := q.db.ExecContext(ctx, removePlaylistTrackByPath, arg.PlaylistID, arg.FilePath)
return err
}
const updatePlaylistName = `-- name: UpdatePlaylistName :exec
UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`
+7 -5
View File
@@ -25,15 +25,17 @@ const (
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
TrackListConfigChanged = "TrackListConfigChanged"
FavoritesConfigChanged = "FavoritesConfigChanged"
)
// Playlist events.
const (
PlaylistCreated = "PlaylistCreated"
PlaylistDeleted = "PlaylistDeleted"
PlaylistRenamed = "PlaylistRenamed"
PlaylistTracksChanged = "PlaylistTracksChanged"
PlaylistsRestored = "PlaylistsRestored"
PlaylistCreated = "PlaylistCreated"
PlaylistDeleted = "PlaylistDeleted"
PlaylistRenamed = "PlaylistRenamed"
PlaylistTracksChanged = "PlaylistTracksChanged"
PlaylistsRestored = "PlaylistsRestored"
DefaultPlaylistChanged = "DefaultPlaylistChanged"
)
// Library events.
+61
View File
@@ -0,0 +1,61 @@
// Package favorites manages the default playlist configuration.
package favorites
import (
"errors"
"fmt"
)
var errUnknownIconStyle = errors.New(
"unknown favorites icon style",
)
// IconStyle controls the icon used to indicate favourited tracks.
type IconStyle string
// Valid IconStyle values.
const (
// IconHeart uses a heart icon.
IconHeart IconStyle = "heart"
// IconStar uses a star icon.
IconStar IconStyle = "star"
)
// DefaultIconStyle is applied when no value has been set.
const DefaultIconStyle = IconHeart
// DefaultPlaylistName is the name given to the auto-created
// default playlist.
const DefaultPlaylistName = "Favorites"
// Config holds favourites preferences.
type Config struct {
PlaylistID int64 `toml:"PlaylistID"`
IconStyle IconStyle `toml:"IconStyle"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
func (c *Config) ApplyDefaults() {
if c.IconStyle == "" {
c.IconStyle = DefaultIconStyle
}
}
// Validate checks that all values are well-formed.
func (c *Config) Validate() error {
c.ApplyDefaults()
switch c.IconStyle {
case IconHeart, IconStar:
// Valid.
default:
return fmt.Errorf(
"%w: %q",
errUnknownIconStyle,
c.IconStyle,
)
}
return nil
}
+348
View File
@@ -0,0 +1,348 @@
package playlist
import (
"errors"
"fmt"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/favorites"
)
var errNoDefaultPlaylist = errors.New(
"no default playlist configured",
)
// FavoritesConfigProvider is a narrow interface for reading and
// writing the default-playlist configuration.
type FavoritesConfigProvider interface {
GetFavoritesPlaylistID() int64
SetFavoritesPlaylistID(id int64) error
GetFavoritesIconStyle() string
}
// EnsureDefaultPlaylist verifies the configured default playlist
// exists in the database. If the playlist is missing or no ID
// has been configured yet, a new playlist named "Favorites" is
// created and the config is updated.
func (s *Service) EnsureDefaultPlaylist() {
if s.favoritesConf == nil {
s.logger.Warn(
"No favorites config provider, skipping",
)
return
}
id := s.favoritesConf.GetFavoritesPlaylistID()
// Check whether the playlist still exists.
if id > 0 {
_, err := s.db.Queries.GetPlaylist(
s.db.Ctx, id,
)
if err == nil {
return // Playlist exists, nothing to do.
}
s.logger.Warn(
"Default playlist not found, recreating",
"configuredId", id,
)
}
// Create a fresh default playlist.
created, err := s.db.Queries.CreatePlaylist(
s.db.Ctx, favorites.DefaultPlaylistName,
)
if err != nil {
s.logger.Error(
"Failed to create default playlist",
"err", err,
)
return
}
s.savePlaylistFile(created.ID, created.Name)
if setErr := s.favoritesConf.SetFavoritesPlaylistID(
created.ID,
); setErr != nil {
s.logger.Error(
"Failed to save default playlist ID",
"err", setErr,
)
}
s.logger.Info(
"Default playlist created",
"id", created.ID,
"name", created.Name,
)
s.emitEvent(events.PlaylistCreated, Summary{
ID: created.ID, Name: created.Name,
})
}
// GetDefaultPlaylistTrackPaths returns the file paths of all
// tracks in the default playlist.
func (s *Service) GetDefaultPlaylistTrackPaths() (
[]string,
error,
) {
id := s.defaultPlaylistID()
if id == 0 {
return []string{}, nil
}
paths, err := s.db.Queries.GetPlaylistTrackFilePaths(
s.db.Ctx, id,
)
if err != nil {
s.logger.Error(
"Failed to get default playlist paths",
"playlistId", id,
"err", err,
)
return nil, fmt.Errorf(
"failed to get default playlist paths: %w",
err,
)
}
if paths == nil {
paths = []string{}
}
return paths, nil
}
// GetDefaultPlaylistInfo returns the ID and name of the default
// playlist for display in the frontend.
func (s *Service) GetDefaultPlaylistInfo() (
Summary,
error,
) {
id := s.defaultPlaylistID()
if id == 0 {
return Summary{}, nil
}
pl, err := s.db.Queries.GetPlaylist(s.db.Ctx, id)
if err != nil {
return Summary{}, fmt.Errorf(
"failed to get default playlist: %w", err,
)
}
return Summary{ID: pl.ID, Name: pl.Name}, nil
}
// ToggleDefaultPlaylistTrack adds or removes a single track
// from the default playlist. Returns true if the track is now
// in the playlist (was added), false if it was removed.
func (s *Service) ToggleDefaultPlaylistTrack(
filePath string,
) (bool, error) {
id := s.defaultPlaylistID()
if id == 0 {
return false, errNoDefaultPlaylist
}
inPlaylist, err := s.db.Queries.IsTrackInPlaylist(
s.db.Ctx,
sqlcgen.IsTrackInPlaylistParams{
PlaylistID: id,
FilePath: filePath,
},
)
if err != nil {
return false, fmt.Errorf(
"failed to check playlist membership: %w",
err,
)
}
if inPlaylist != 0 {
// Remove.
if rmErr := s.db.Queries.RemovePlaylistTrackByPath(
s.db.Ctx,
sqlcgen.RemovePlaylistTrackByPathParams{
PlaylistID: id,
FilePath: filePath,
},
); rmErr != nil {
return false, fmt.Errorf(
"failed to remove track: %w", rmErr,
)
}
s.savePlaylistFileByID(id)
s.emitEvent(
events.DefaultPlaylistChanged,
map[string]any{
"filePath": filePath,
"added": false,
},
)
s.emitEvent(events.PlaylistTracksChanged, id)
return false, nil
}
// Add.
nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition(
s.db.Ctx, id,
)
if posErr != nil {
return false, fmt.Errorf(
"failed to get next position: %w", posErr,
)
}
if addErr := s.addSingleTrack(
id, filePath, nextPos,
); addErr != nil {
return false, fmt.Errorf(
"failed to add track: %w", addErr,
)
}
s.savePlaylistFileByID(id)
s.emitEvent(
events.DefaultPlaylistChanged,
map[string]any{
"filePath": filePath,
"added": true,
},
)
s.emitEvent(events.PlaylistTracksChanged, id)
return true, nil
}
// AddToDefaultPlaylist adds multiple tracks to the default
// playlist, skipping any that are already present.
func (s *Service) AddToDefaultPlaylist(
filePaths []string,
) error {
id := s.defaultPlaylistID()
if id == 0 {
return errNoDefaultPlaylist
}
nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
s.db.Ctx, id,
)
if err != nil {
return fmt.Errorf(
"failed to get next position: %w", err,
)
}
var added int
for _, fp := range filePaths {
inPlaylist, chkErr := s.db.Queries.IsTrackInPlaylist(
s.db.Ctx,
sqlcgen.IsTrackInPlaylistParams{
PlaylistID: id,
FilePath: fp,
},
)
if chkErr != nil {
s.logger.Warn(
"Could not check playlist membership",
"filePath", fp,
"err", chkErr,
)
continue
}
if inPlaylist != 0 {
continue
}
if addErr := s.addSingleTrack(
id, fp, nextPos+int64(added),
); addErr != nil {
s.logger.Warn(
"Could not add track to default playlist",
"filePath", fp,
"err", addErr,
)
continue
}
added++
}
if added > 0 {
s.savePlaylistFileByID(id)
s.emitEvent(
events.DefaultPlaylistChanged, nil,
)
s.emitEvent(events.PlaylistTracksChanged, id)
}
return nil
}
// RemoveFromDefaultPlaylist removes multiple tracks from the
// default playlist.
func (s *Service) RemoveFromDefaultPlaylist(
filePaths []string,
) error {
id := s.defaultPlaylistID()
if id == 0 {
return errNoDefaultPlaylist
}
var removed int
for _, fp := range filePaths {
rmErr := s.db.Queries.RemovePlaylistTrackByPath(
s.db.Ctx,
sqlcgen.RemovePlaylistTrackByPathParams{
PlaylistID: id,
FilePath: fp,
},
)
if rmErr != nil {
s.logger.Warn(
"Could not remove track from default playlist",
"filePath", fp,
"err", rmErr,
)
continue
}
removed++
}
if removed > 0 {
s.savePlaylistFileByID(id)
s.emitEvent(
events.DefaultPlaylistChanged, nil,
)
s.emitEvent(events.PlaylistTracksChanged, id)
}
return nil
}
// defaultPlaylistID returns the configured default playlist ID,
// or 0 if not configured.
func (s *Service) defaultPlaylistID() int64 {
if s.favoritesConf == nil {
return 0
}
return s.favoritesConf.GetFavoritesPlaylistID()
}
+20 -4
View File
@@ -96,10 +96,11 @@ type PhantomSearchResult struct {
// Service manages playlist operations.
type Service struct {
ctx context.Context
logger *slog.Logger
db *database.DB
libraryDir LibraryDirProvider
ctx context.Context
logger *slog.Logger
db *database.DB
libraryDir LibraryDirProvider
favoritesConf FavoritesConfigProvider
}
// NewService creates a new playlist service.
@@ -115,6 +116,14 @@ func NewService(
}
}
// SetFavoritesConfig sets the provider used to read and write
// the default-playlist configuration.
func (s *Service) SetFavoritesConfig(
provider FavoritesConfigProvider,
) {
s.favoritesConf = provider
}
// SetContext sets the Wails runtime context and runs the
// one-time startup migration to bootstrap M3U8 files for
// existing playlists.
@@ -583,6 +592,8 @@ func (s *Service) RemoveTracksFromPlaylist(
}
// DeletePlaylist deletes a playlist and its M3U8 file.
// If the deleted playlist was the default, a new default
// playlist is automatically created.
func (s *Service) DeletePlaylist(playlistID int64) error {
if err := s.db.Queries.DeletePlaylist(
s.db.Ctx, playlistID,
@@ -606,6 +617,11 @@ func (s *Service) DeletePlaylist(playlistID int64) error {
s.emitEvent(events.PlaylistDeleted, playlistID)
// Recreate the default playlist if we just deleted it.
if s.defaultPlaylistID() == playlistID {
s.EnsureDefaultPlaylist()
}
return nil
}