Skip to content

Commit a96e616

Browse files
fix(playlists): refresh agent-created playlists
Reload playlist views when the Genius flow creates a playlist so saved playlists appear immediately. Emit the standard playlist update event from the agent path to keep creation behavior consistent across the app.
1 parent c8c0ccf commit a96e616

File tree

4 files changed

+309
-7
lines changed

4 files changed

+309
-7
lines changed

app/frontend/__tests__/playlist-events.props.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,21 @@ describe('Playlist Event Propagation - Property-Based Tests', () => {
274274
);
275275
});
276276

277+
describe('Sidebar Playlist Refresh', () => {
278+
it('reloads playlists when mt:playlists-updated fires after init', () => {
279+
sidebar.loadSection = vi.fn();
280+
281+
sidebar.init();
282+
sidebar.loadPlaylists.mockClear();
283+
284+
window.dispatchEvent(new CustomEvent('mt:playlists-updated'));
285+
286+
expect(sidebar.loadPlaylists).toHaveBeenCalledTimes(1);
287+
288+
sidebar.destroy?.();
289+
});
290+
});
291+
277292
describe('New Playlist Creation Event Propagation', () => {
278293
test.prop([playlistNameArbitrary])(
279294
'committing a new playlist rename dispatches event',

app/frontend/js/components/sidebar.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export function createSidebar(Alpine) {
4242
contextMenuX: 0,
4343
contextMenuY: 0,
4444

45+
_onPlaylistsUpdated: null,
46+
4547
init() {
4648
this._initSettings();
4749
console.log('[Sidebar] Component initialized, drag handlers available:', {
@@ -61,6 +63,18 @@ export function createSidebar(Alpine) {
6163
window.addEventListener('mt:create-playlist-with-tracks', async (e) => {
6264
await this.createPlaylistWithTracks(e.detail?.trackIds || []);
6365
});
66+
67+
this._onPlaylistsUpdated = () => {
68+
this.loadPlaylists();
69+
};
70+
window.addEventListener('mt:playlists-updated', this._onPlaylistsUpdated);
71+
},
72+
73+
destroy() {
74+
if (this._onPlaylistsUpdated) {
75+
window.removeEventListener('mt:playlists-updated', this._onPlaylistsUpdated);
76+
this._onPlaylistsUpdated = null;
77+
}
6478
},
6579

6680
/**

crates/mt-tauri/src/agent/mod.rs

Lines changed: 278 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod setup;
99
pub mod tools;
1010
pub mod types;
1111

12+
use std::collections::HashMap;
1213
use std::sync::Arc;
1314

1415
use rig::client::CompletionClient;
@@ -24,8 +25,116 @@ use tools::{
2425
use types::{AgentContext, AgentError, AgentResponse, AgentStatusResponse, ParsedPlaylist};
2526

2627
use crate::db::{Database, playlists};
28+
use crate::events::{EventEmitter, PlaylistsUpdatedEvent};
2729
use crate::lastfm::LastFmClient;
2830

31+
/// Generate a unique playlist name by appending a number if needed.
32+
///
33+
/// If the base name exists, tries "Name (2)", "Name (3)", etc.
34+
fn generate_unique_playlist_name(conn: &rusqlite::Connection, base_name: &str) -> crate::db::DbResult<String> {
35+
// Check if base name is available
36+
let exists: bool = conn.query_row(
37+
"SELECT EXISTS(SELECT 1 FROM playlists WHERE name = ? LIMIT 1)",
38+
[base_name],
39+
|row| row.get(0),
40+
)?;
41+
42+
if !exists {
43+
return Ok(base_name.to_string());
44+
}
45+
46+
// Find an available suffix
47+
for i in 2..=100 {
48+
let candidate = format!("{} ({})", base_name, i);
49+
let exists: bool = conn.query_row(
50+
"SELECT EXISTS(SELECT 1 FROM playlists WHERE name = ? LIMIT 1)",
51+
[&candidate],
52+
|row| row.get(0),
53+
)?;
54+
if !exists {
55+
return Ok(candidate);
56+
}
57+
}
58+
59+
// Fallback: append timestamp if all numbers are taken
60+
use std::time::{SystemTime, UNIX_EPOCH};
61+
let timestamp = SystemTime::now()
62+
.duration_since(UNIX_EPOCH)
63+
.unwrap_or_default()
64+
.as_secs();
65+
Ok(format!("{} ({})", base_name, timestamp))
66+
}
67+
68+
/// Shuffle tracks to spread out same-artist tracks for a better mix.
69+
///
70+
/// Uses a greedy approach: repeatedly pick the track whose artist is least
71+
/// recently used. This ensures no adjacent tracks from the same artist.
72+
fn shuffle_spread_artists(tracks: &[(i64, String)]) -> Vec<i64> {
73+
if tracks.is_empty() {
74+
return Vec::new();
75+
}
76+
77+
// Group track IDs by artist
78+
let mut by_artist: HashMap<&str, Vec<i64>> = HashMap::new();
79+
let mut artist_keys: HashMap<i64, &str> = HashMap::new();
80+
81+
for (id, artist) in tracks {
82+
by_artist
83+
.entry(artist.as_str())
84+
.or_default()
85+
.push(*id);
86+
artist_keys.insert(*id, artist.as_str());
87+
}
88+
89+
// Shuffle each artist's tracks locally (for variety)
90+
for artist_tracks in by_artist.values_mut() {
91+
use rand::seq::SliceRandom;
92+
artist_tracks.shuffle(&mut rand::rng());
93+
}
94+
95+
// Greedy selection: always pick from the artist with most remaining tracks
96+
// who wasn't just played
97+
let mut result: Vec<i64> = Vec::with_capacity(tracks.len());
98+
let mut last_artist: Option<&str> = None;
99+
100+
while result.len() < tracks.len() {
101+
// Find artists with tracks remaining, excluding last_artist if possible
102+
let mut available: Vec<(&str, usize)> = by_artist
103+
.iter()
104+
.filter(|(_, ids)| !ids.is_empty())
105+
.filter(|(artist, _)| Some(**artist) != last_artist)
106+
.map(|(artist, ids)| (*artist, ids.len()))
107+
.collect();
108+
109+
// If no one else available, we have to use last_artist
110+
if available.is_empty() {
111+
available = by_artist
112+
.iter()
113+
.filter(|(_, ids)| !ids.is_empty())
114+
.map(|(artist, ids)| (*artist, ids.len()))
115+
.collect();
116+
}
117+
118+
if available.is_empty() {
119+
break;
120+
}
121+
122+
// Pick artist with most remaining tracks (greedy)
123+
available.sort_by(|a, b| b.1.cmp(&a.1));
124+
let chosen_artist = available[0].0;
125+
126+
// Take one track from that artist
127+
if let Some(ids) = by_artist.get_mut(chosen_artist) {
128+
if let Some(track_id) = ids.pop() {
129+
result.push(track_id);
130+
last_artist = Some(chosen_artist);
131+
}
132+
}
133+
}
134+
135+
result
136+
}
137+
29138
/// Parse model names from Ollama's `/api/tags` JSON response.
30139
///
31140
/// Expected format: `{ "models": [{ "name": "llama3.2:1b", ... }, ...] }`
@@ -162,6 +271,7 @@ fn has_default_model(models: &[String]) -> bool {
162271
///
163272
/// Flow: health check → build agent → prompt (multi-turn) → parse → create playlist.
164273
pub async fn agent_generate_playlist(
274+
app: tauri::AppHandle,
165275
prompt: String,
166276
db: tauri::State<'_, Database>,
167277
) -> Result<AgentResponse, String> {
@@ -208,37 +318,76 @@ pub async fn agent_generate_playlist(
208318
}
209319
};
210320

211-
// 6. Create the playlist in the database
321+
// 6. Fetch track details for the parsed IDs (to get artist names for shuffling)
322+
let track_details: Vec<(i64, String)> = db
323+
.with_conn(|conn| {
324+
let mut results = Vec::new();
325+
for id in &parsed.track_ids {
326+
let mut stmt = conn.prepare("SELECT id, artist FROM library WHERE id = ? AND missing = 0 LIMIT 1")?;
327+
let mut rows = stmt.query([id])?;
328+
if let Some(row) = rows.next()? {
329+
let track_id: i64 = row.get(0)?;
330+
let artist: String = row.get(1)?;
331+
results.push((track_id, artist));
332+
}
333+
}
334+
Ok(results)
335+
})
336+
.map_err(|e| format!("Failed to fetch track details: {e}"))?;
337+
338+
if track_details.is_empty() {
339+
return Ok(AgentResponse::error(
340+
"None of the selected tracks exist in your library".to_string()
341+
));
342+
}
343+
344+
// 7. Shuffle tracks to spread out same-artist tracks
345+
let shuffled_ids = shuffle_spread_artists(&track_details);
346+
let valid_count = shuffled_ids.len();
347+
348+
info!(
349+
requested = parsed.track_ids.len(),
350+
valid = valid_count,
351+
"Validated and shuffled tracks"
352+
);
353+
354+
// 8. Create the playlist in the database (with unique name handling)
355+
let playlist_name: String = db
356+
.with_conn(|conn| generate_unique_playlist_name(conn, &parsed.name))
357+
.map_err(|e: crate::db::DbError| format!("Failed to generate playlist name: {e}"))?;
358+
212359
let playlist = db
213-
.with_conn(|conn| playlists::create_playlist(conn, &parsed.name))
360+
.with_conn(|conn| playlists::create_playlist(conn, &playlist_name))
214361
.map_err(|e| format!("Failed to create playlist: {e}"))?;
215362

216363
let playlist = match playlist {
217364
Some(p) => p,
218365
None => {
219366
return Ok(AgentResponse::error(format!(
220367
"Playlist name '{}' already exists",
221-
parsed.name
368+
playlist_name
222369
)));
223370
}
224371
};
225372

226373
let added = db
227374
.with_conn(|conn| {
228-
playlists::add_tracks_to_playlist(conn, playlist.id, &parsed.track_ids, None)
375+
playlists::add_tracks_to_playlist(conn, playlist.id, &shuffled_ids, None)
229376
})
230377
.map_err(|e| format!("Failed to add tracks to playlist: {e}"))?;
231378

232379
info!(
233380
playlist_id = playlist.id,
234-
playlist_name = %parsed.name,
381+
playlist_name = %playlist_name,
235382
track_count = added,
236383
"Agent playlist created"
237384
);
238385

386+
let _ = app.emit_playlists_updated(PlaylistsUpdatedEvent::created(playlist.id));
387+
239388
Ok(AgentResponse::success(
240389
playlist.id,
241-
parsed.name,
390+
playlist_name,
242391
added as usize,
243392
))
244393
}
@@ -445,6 +594,129 @@ mod tests {
445594
(1..=25).map(|i| i as i64).collect::<Vec<_>>()
446595
);
447596
}
597+
598+
// -----------------------------------------------------------------------
599+
// shuffle_spread_artists tests
600+
// -----------------------------------------------------------------------
601+
602+
#[test]
603+
fn shuffle_spread_artists_empty_returns_empty() {
604+
let tracks: Vec<(i64, String)> = vec![];
605+
let result = shuffle_spread_artists(&tracks);
606+
assert!(result.is_empty());
607+
}
608+
609+
#[test]
610+
fn shuffle_spread_artists_spreads_same_artist_apart() {
611+
// 6 tracks: 3 from Artist A, 3 from Artist B
612+
let tracks = vec![
613+
(1, "Artist A".into()),
614+
(2, "Artist A".into()),
615+
(3, "Artist A".into()),
616+
(4, "Artist B".into()),
617+
(5, "Artist B".into()),
618+
(6, "Artist B".into()),
619+
];
620+
let result = shuffle_spread_artists(&tracks);
621+
assert_eq!(result.len(), 6);
622+
623+
// Verify no two adjacent tracks have the same artist
624+
// First, build a map of id -> artist
625+
let artist_map: std::collections::HashMap<i64, &str> = tracks
626+
.iter()
627+
.map(|(id, artist)| (*id, artist.as_str()))
628+
.collect();
629+
630+
for window in result.windows(2) {
631+
let artist1 = artist_map.get(&window[0]).unwrap();
632+
let artist2 = artist_map.get(&window[1]).unwrap();
633+
assert_ne!(
634+
artist1, artist2,
635+
"Adjacent tracks should not have the same artist"
636+
);
637+
}
638+
}
639+
640+
#[test]
641+
fn shuffle_spread_artists_preserves_all_tracks() {
642+
let tracks = vec![
643+
(1, "Artist A".into()),
644+
(2, "Artist B".into()),
645+
(3, "Artist C".into()),
646+
(4, "Artist A".into()),
647+
(5, "Artist B".into()),
648+
];
649+
let result = shuffle_spread_artists(&tracks);
650+
651+
// All 5 track IDs should be in the result
652+
assert_eq!(result.len(), 5);
653+
let mut sorted = result.clone();
654+
sorted.sort();
655+
assert_eq!(sorted, vec![1, 2, 3, 4, 5]);
656+
}
657+
658+
#[test]
659+
fn shuffle_spread_artists_single_track() {
660+
let tracks = vec![(42, "Solo Artist".into())];
661+
let result = shuffle_spread_artists(&tracks);
662+
assert_eq!(result, vec![42]);
663+
}
664+
665+
#[test]
666+
fn shuffle_spread_artists_unique_artists_no_change_needed() {
667+
// All different artists - order can stay as-is (shuffled locally per artist)
668+
let tracks = vec![
669+
(1, "Artist A".into()),
670+
(2, "Artist B".into()),
671+
(3, "Artist C".into()),
672+
(4, "Artist D".into()),
673+
];
674+
let result = shuffle_spread_artists(&tracks);
675+
assert_eq!(result.len(), 4);
676+
let mut sorted = result.clone();
677+
sorted.sort();
678+
assert_eq!(sorted, vec![1, 2, 3, 4]);
679+
}
680+
681+
// -----------------------------------------------------------------------
682+
// generate_unique_playlist_name tests
683+
// -----------------------------------------------------------------------
684+
685+
#[test]
686+
fn unique_name_returns_base_when_available() {
687+
let db = Database::new_in_memory().expect("in-memory db");
688+
let name = db
689+
.with_conn(|conn| generate_unique_playlist_name(conn, "My Playlist"))
690+
.expect("generate name");
691+
assert_eq!(name, "My Playlist");
692+
}
693+
694+
#[test]
695+
fn unique_name_appends_number_when_exists() {
696+
let db = Database::new_in_memory().expect("in-memory db");
697+
698+
// Create first playlist
699+
db.with_conn(|conn| playlists::create_playlist(conn, "Chill Vibes"))
700+
.expect("create first")
701+
.expect("first playlist created");
702+
703+
// Second should get (2)
704+
let name2 = db
705+
.with_conn(|conn| generate_unique_playlist_name(conn, "Chill Vibes"))
706+
.expect("generate name2");
707+
assert_eq!(name2, "Chill Vibes (2)");
708+
709+
// Create second playlist
710+
db.with_conn(|conn| playlists::create_playlist(conn, &name2))
711+
.expect("create second")
712+
.expect("second playlist created");
713+
714+
// Third should get (3)
715+
let name3 = db
716+
.with_conn(|conn| generate_unique_playlist_name(conn, "Chill Vibes"))
717+
.expect("generate name3");
718+
assert_eq!(name3, "Chill Vibes (3)");
719+
}
448720
}
449721

450722
#[cfg(test)]

0 commit comments

Comments
 (0)