@@ -9,6 +9,7 @@ pub mod setup;
99pub mod tools;
1010pub mod types;
1111
12+ use std:: collections:: HashMap ;
1213use std:: sync:: Arc ;
1314
1415use rig:: client:: CompletionClient ;
@@ -24,8 +25,116 @@ use tools::{
2425use types:: { AgentContext , AgentError , AgentResponse , AgentStatusResponse , ParsedPlaylist } ;
2526
2627use crate :: db:: { Database , playlists} ;
28+ use crate :: events:: { EventEmitter , PlaylistsUpdatedEvent } ;
2729use 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.
164273pub 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