@@ -84,6 +84,62 @@ function getRouteWaypoints(
return waypoints ;
return waypoints ;
}
}
const artistGenreCache = new Map < string , string [ ] > ( ) ;
async function getArtistGenres ( artistId : string , token : string ) : Promise < string [ ] > {
if ( ! artistId ) return [ ] ;
if ( artistGenreCache . has ( artistId ) ) {
return artistGenreCache . get ( artistId ) || [ ] ;
}
try {
const res = await fetch ( ` https://api.spotify.com/v1/artists/ ${ artistId } ` , {
headers : { Authorization : ` Bearer ${ token } ` }
} ) ;
if ( res . ok ) {
const data = await res . json ( ) ;
const genres = data . genres || [ ] ;
artistGenreCache . set ( artistId , genres ) ;
return genres ;
} else {
console . warn ( ` [SpotifyPlaylists] Failed to fetch artist ${ artistId } : status ${ res . status } ` ) ;
}
} catch ( err ) {
console . warn ( ` [SpotifyPlaylists] Error fetching artist ${ artistId } : ` , err ) ;
}
return [ ] ;
}
function genreMatches ( artistGenres : string [ ] , favoriteGenre : string ) : boolean {
const normalizedFav = favoriteGenre . toLowerCase ( ) . trim ( ) ;
const synonymMap : Record < string , string [ ] > = {
'fado' : [ 'fado' , 'portuguese fado' ] ,
'rock' : [ 'rock' , 'classic rock' , 'alternative rock' ] ,
'rap' : [ 'rap' , 'hip hop' , 'portuguese hip hop' , 'hip-hop' ] ,
'hip hop' : [ 'hip hop' , 'rap' , 'hip-hop' , 'portuguese hip hop' ] ,
'hip-hop' : [ 'hip hop' , 'rap' , 'hip-hop' , 'portuguese hip hop' ] ,
'pop' : [ 'pop' , 'portuguese pop' ] ,
'funk' : [ 'funk' , 'baile funk' , 'funk carioca' ] ,
'electronic' : [ 'electronic' , 'edm' , 'house' , 'techno' , 'electro' ]
} ;
const allowedSynonyms = [
normalizedFav ,
. . . ( synonymMap [ normalizedFav ] || [ ] )
] ;
for ( const genre of artistGenres ) {
const normalizedGenre = genre . toLowerCase ( ) ;
for ( const syn of allowedSynonyms ) {
if ( normalizedGenre === syn || normalizedGenre . includes ( syn ) ) {
return true ;
}
}
}
return false ;
}
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// @ts-ignore
// @ts-ignore
@@ -127,6 +183,8 @@ export default function NewTripScreen({ navigation }) {
let playlistCreationFailed = false ;
let playlistCreationFailed = false ;
let playlistFailureReason = '' ;
let playlistFailureReason = '' ;
let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.' ;
let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.' ;
let hasGenre = false ;
let accumulatedDurationMs = 0 ;
try {
try {
console . log ( "GENERATING_PLAYLIST_FOR_TRIP:" , tripName ) ;
console . log ( "GENERATING_PLAYLIST_FOR_TRIP:" , tripName ) ;
@@ -304,311 +362,313 @@ export default function NewTripScreen({ navigation }) {
console . log ( "PLAYLIST_FAVORITE_GENRE_USED:" , favoriteGenre || "none" ) ;
console . log ( "PLAYLIST_FAVORITE_GENRE_USED:" , favoriteGenre || "none" ) ;
const ollamaPrompt = ` I am taking a roadtrip from ${ o rigin } to ${ destination } .
hasGenre = Boolean ( favoriteGenre && favoriteGenre . t rim ( ) . length > 0 ) ;
The trip is called " ${ tripName } " and takes about ${ duration } .
const cleanGenre = hasGenre ? favoriteGenre . trim ( ) . toLowerCase ( ) : "" ;
The user's favorite music genre is: " ${ favoriteGenre || "not set" } ".
Reply ONLY with a JSON array of up to 10 Spotify search queries.
console . log ( '[Playlist] favoriteMusicStyle:' , favoriteGenre || '(empty)' ) ;
The queries should be varied and specific to this trip.
If a favorite genre is set, strongly include it in the search ideas.
Do NOT return song names.
Do NOT return explanations.
Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie"]. ` ;
let aiQueries : string [ ] = [ ] ;
// ── Style → curated query map (artist-based; genre: filter unreliable for tracks)
const STYLE_QUERY_MAP : Record < string , string [ ] > = {
fado : [
'Amália Rodrigues' , 'Mariza fado' , 'Carlos do Carmo' , 'Ana Moura' ,
'Dulce Pontes' , 'Camané' , 'Carminho fado' , 'Mísia fado' ,
'Madredeus fado' , 'João Braga fado' , 'Rodrigo fado' , 'Celeste Rodrigues' ,
'fado português' , 'fado clássico' , 'fado moderno' , 'fado novo' ,
'músicas de fado' , 'fado Lisboa' , 'fado Coimbra' ,
] ,
rock : [
'classic rock hits' , 'rock road trip' , 'alternative rock' ,
'rock classics' , 'rock driving' , 'hard rock hits' , 'rock anthems' ,
'indie rock' , 'portuguese rock' , 'rock nacional' ,
] ,
rap : [
'rap português' , 'portuguese hip hop' , 'rap viagem' , 'hip hop road trip' ,
'trap hits' , 'rap nacional' , 'rap clássico' , 'rap popular' ,
] ,
'hip hop' : [
'hip hop road trip' , 'hip hop hits' , 'rap viagem' ,
'portuguese hip hop' , 'trap music' , 'hip hop classics' ,
] ,
pop : [
'pop português' , 'pop hits' , 'pop road trip' , 'pop viagem' ,
'pop clássico' , 'pop moderno' , 'pop nacional' ,
] ,
funk : [
'funk hits' , 'baile funk' , 'funk road trip' , 'funk clássico' ,
'funk carioca' , 'funk nacional' ,
] ,
electronic : [
'electronic road trip' , 'edm hits' , 'house music driving' ,
'techno' , 'electronic dance' , 'electro hits' ,
] ,
} ;
// Resolve style queries: use STYLE_QUERY_MAP if we recognise the genre, else build generic ones
const styleQueries : string [ ] = hasGenre
? (
STYLE_QUERY_MAP [ cleanGenre ] ? ?
[
cleanGenre ,
` ${ cleanGenre } hits ` ,
` ${ cleanGenre } popular ` ,
` ${ cleanGenre } viagem ` ,
` ${ cleanGenre } clássico ` ,
` ${ cleanGenre } moderno ` ,
` músicas de ${ cleanGenre } ` ,
]
)
: [ ] ;
// AI supplement: ask Ollama for artists/songs of the style only when genre is set
const ollamaPrompt = hasGenre
? ` The user's favorite music genre is: " ${ cleanGenre } ".
Reply ONLY with a JSON array of up to 10 well-known song titles or artist names that belong strictly to the " ${ cleanGenre } " genre.
Do NOT include songs from other genres.
Do NOT include explanation or markdown.
Example for fado: ["Amália Rodrigues", "Mariza", "Ana Moura fado", "Carlos do Carmo"]. `
: ` I am taking a roadtrip from ${ origin } to ${ destination } (" ${ tripName } ", ${ duration } ).
Reply ONLY with a JSON array of up to 10 varied Spotify search queries for a road trip playlist.
Do NOT return song names. Do NOT return explanations.
Example: ["feel good road trip", "summer hits", "driving pop hits"]. ` ;
let aiArtistQueries : string [ ] = [ ] ;
try {
try {
const ollamaRes = await fetch ( ` ${ OLLAMA_API_URL } /api/chat ` , {
const ollamaRes = await fetch ( ` ${ OLLAMA_API_URL } /api/chat ` , {
method : " POST" ,
method : ' POST' ,
headers : { " Content-Type" : " application/json" } ,
headers : { ' Content-Type' : ' application/json' } ,
body : JSON.stringify ( {
body : JSON.stringify ( {
model : " qwen3-coder:30b" ,
model : ' qwen3-coder:30b' ,
messages : [ { role : " user" , content : ollamaPrompt } ] ,
messages : [ { role : ' user' , content : ollamaPrompt } ] ,
stream : false ,
stream : false ,
} ) ,
} ) ,
} ) ;
} ) ;
const ollamaData = await safeParseJson ( ollamaRes , 'Ollama' ) ;
const ollamaData = await safeParseJson ( ollamaRes , "Ollama" ) ;
let rawAiText = ( ollamaData ? . message ? . content || '' )
let rawAiText = ollamaData ? . message ? . content || "" ;
. replace ( /```json/g , '' ) . replace ( /```/g , '' ) . trim ( ) ;
if ( rawAiText . startsWith ( '[' ) ) {
rawAiText = rawAiText
. replace ( /```json/g , "" )
. replace ( /```/g , "" )
. trim ( ) ;
if ( rawAiText . length > 0 && rawAiText . startsWith ( "[" ) ) {
const parsed = JSON . parse ( rawAiText ) ;
const parsed = JSON . parse ( rawAiText ) ;
if ( Array . isArray ( parsed ) ) {
if ( Array . isArray ( parsed ) ) {
aiQueries = parsed
aiArtist Queries = parsed . map ( String ) . map ( cleanSearchQuery ) . filter ( Boolean ) . slice ( 0 , 10 ) ;
. map ( String )
. map ( cleanSearchQuery )
. filter ( Boolean )
. slice ( 0 , 10 ) ;
}
}
}
}
} catch ( aiError ) {
console . log ( "AI parsing failed, using fallback queries." , aiError ) ;
}
const favoriteGenreQueries = favoriteGenre
? [
` ${ favoriteGenre } road trip ` ,
` ${ favoriteGenre } hits ` ,
` ${ favoriteGenre } travel songs ` ,
` ${ favoriteGenre } driving music ` ,
` ${ favoriteGenre } playlist ` ,
` ${ favoriteGenre } ${ destination } ` ,
]
: [ ] ;
const tripSpecificQueries = [
` ${ destination } road trip ` ,
` ${ origin } to ${ destination } music ` ,
` ${ tripName } playlist ` ,
` ${ destination } travel songs ` ,
` ${ origin } ${ destination } road trip ` ,
] ;
const fallbackQueries = [
"road trip songs" ,
"travel songs" ,
"summer hits" ,
"top hits Portugal" ,
"pop hits" ,
"driving music" ,
"feel good road trip" ,
"european travel music" ,
"party road trip" ,
"indie road trip" ,
] ;
const firstQueries = [
. . . favoriteGenreQueries ,
. . . aiQueries ,
]
. map ( cleanSearchQuery )
. filter ( Boolean ) ;
const remainingQueries = [
. . . tripSpecificQueries ,
. . . fallbackQueries ,
]
. map ( cleanSearchQuery )
. filter ( Boolean ) ;
const searchQueries = Array . from (
new Set ( [
. . . firstQueries ,
. . . shuffleArray ( remainingQueries ) ,
] )
) ;
const playlistRandomSeed = ` ${ Date . now ( ) } - ${ Math . random ( )
. toString ( 36 )
. slice ( 2 ) } ` ;
console . log ( "PLAYLIST_RANDOM_SEED:" , playlistRandomSeed ) ;
console . log ( "PLAYLIST_MUSIC_QUERIES:" , searchQueries ) ;
// D. Create empty playlist
const createPlaylistUrl = "https://api.spotify.com/v1/me/playlists" ;
const createPlaylistBody = JSON . stringify ( {
name : tripName ,
description : ` Roadtrip from ${ origin } to ${ destination } . Themes: ${ searchQueries
. slice ( 0 , 8 )
. join ( ", " ) } ` ,
public : false ,
} ) ;
console . log ( "CREATE_PLAYLIST_URL:" , createPlaylistUrl ) ;
console . log ( "CREATE_PLAYLIST_BODY:" , createPlaylistBody ) ;
const createPlaylistRes = await fetch ( createPlaylistUrl , {
method : "POST" ,
headers : {
Authorization : ` Bearer ${ providerToken } ` ,
"Content-Type" : "application/json" ,
} ,
body : createPlaylistBody ,
} ) ;
console . log ( "CREATE_PLAYLIST_HTTP_STATUS:" , createPlaylistRes . status ) ;
const createPlaylistResText = await createPlaylistRes . text ( ) ;
if ( ! createPlaylistRes . ok ) {
console . log (
"CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:" ,
createPlaylistResText . substring ( 0 , 300 )
) ;
if ( createPlaylistRes . status === 403 ) {
await clearSpotifyTokens ( ) ;
console . warn (
"CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect."
) ;
Alert . alert (
"Permissão Spotify Necessária" ,
"Reconecta o Spotify para dar permissão de criar playlists."
) ;
playlistCreationFailed = true ;
playlistFailureReason = "scope" ;
throw new Error (
"CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required."
) ;
}
throw new Error (
` Spotify API returned status ${ createPlaylistRes . status
} for CreatePlaylist: ${ createPlaylistResText . substring ( 0 , 150 ) } `
) ;
}
let playlistData : any ;
try {
playlistData = JSON . parse ( createPlaylistResText ) ;
} catch {
} catch {
throw new Error (
console . log ( '[Playlist] AI query generation failed; continuing without it.' ) ;
` Failed to parse JSON response for CreatePlaylist: ${ createPlaylistResText . substring (
0 ,
150
) } `
) ;
}
}
if ( ! playlistData . id ) {
// Generic roadtrip filler queries (only used in Phase 2 when no genre, or to pad when genre exhausted)
throw new Error ( "Could not create playlist" ) ;
const genericFillerQueries = [
}
` ${ destination } road trip ` , ` ${ origin } to ${ destination } music ` ,
'road trip hits' , 'travel songs' , 'driving music' ,
'summer hits' , 'feel good road trip' , 'top hits Portugal' ,
] . map ( cleanSearchQuery ) . filter ( Boolean ) ;
const playlistId = playlistData . id ;
// ── Target track count (based on trip duration, ~4 min per song)
generatedPlaylistUrl = playlistData . external_urls . spotify ;
const tripDurationMinutes = tripDurationMs / 60000 ;
const TARGET_TRACK_COUNT = Math . max ( 10 , Math . ceil ( tripDurationMinutes / 4 ) ) ;
const STYLE_RATIO = 0.80 ;
const styleTargetCount = hasGenre ? Math . ceil ( TARGET_TRACK_COUNT * STYLE_RATIO ) : 0 ;
// E. Fill playlist with varied tracks based on duration
console . log ( '[Playlist] targetTrackCount:' , TARGET_TRACK_COUNT ) ;
console . log ( "TARGET_PLAYLIST_DURATION_MS:" , tripDurationMs ) ;
console . log ( '[Playlist] styleTargetCount:' , styleTargetCount ) ;
console . log ( '[Playlist] styleQueries:' , styleQueries ) ;
let accumulatedDurationMs = 0 ;
const selectedTracks : SelectedSpotifyTrack [ ] = [ ] ;
const selectedTrackIds = new Set < string > ( ) ;
const artistCount = new Map < string , number > ( ) ;
let searchRequestsCount = 0 ;
let queryIndex = 0 ;
const MAX_SEARCH_REQUESTS = 40 ;
const MAX_TRACKS = 400 ;
const MAX_TRACKS_PER_ARTIST = 3 ;
const MAX_TRACKS_PER_ARTIST = 3 ;
while (
// Helper to run Spotify track search for a given query and return accepted tracks
accumulatedDurationMs < tripDurationMs &&
const selectedTrackIds = new Set < string > ( ) ;
searchReque sts Count < MAX_SEARCH_REQUESTS &&
const arti stCount = new Map < string , number > ( ) ;
selectedTracks . length < MAX_TRACKS &&
let totalRawResultsCount = 0 ;
searchQueries . length > 0
let tracksRejectedCount = 0 ;
) {
let searchRequestsCount = 0 ;
const currentQuery = searchQueries [ queryIndex % searchQueries . length ] ;
const queryEncoded = encodeURIComponent ( currentQuery ) ;
const offset = getRandomSpotifyOffset ( ) ;
const searchAndFilter = async (
query : string ,
validateGenre : boolean ,
) : Promise < SelectedSpotifyTrack [ ] > = > {
const results : SelectedSpotifyTrack [ ] = [ ] ;
const offsets = [ 0 , 10 , 20 , 30 ] ;
for ( const offset of offsets ) {
if ( accumulatedDurationMs >= tripDurationMs ) break ;
if ( searchRequestsCount >= 60 ) break ;
const queryEncoded = encodeURIComponent ( query ) ;
const searchUrl =
const searchUrl =
` https://api.spotify.com/v1/search ` +
` https://api.spotify.com/v1/search?type=track ` +
` ?type=track ` +
` &q= ${ queryEncoded } &limit=20&market= ${ spotifyUserCountry } &offset= ${ offset } ` ;
` &q= ${ queryEncoded } ` +
` &limit=10 ` +
` &market= ${ spotifyUserCountry } ` +
` &offset= ${ offset } ` ;
console . log ( " TRACK_SEARCH_QUERY:" , currentQuery ) ;
console . log ( ' TRACK_SEARCH_QUERY:' , query , '| offset:' , offset ) ;
console . log ( "TRACK_SEARCH_OFFSET:" , offset ) ;
console . log ( "TRACK_SEARCH_URL:" , searchUrl ) ;
const searchRes = await fetch ( searchUrl , {
const searchRes = await fetch ( searchUrl , {
headers : {
headers : { Authorization : ` Bearer ${ providerToken } ` , 'Content-Type' : 'application/json' } ,
Authorization : ` Bearer ${ providerToken } ` ,
"Content-Type" : "application/json" ,
} ,
} ) ;
} ) ;
console . log ( "TRACK_SEARCH_STATUS:" , searchRes . status ) ;
searchRequestsCount ++ ;
if ( ! searchRes . ok ) {
if ( ! searchRes . ok ) {
const errText = await searchRes . text ( ) ;
const errText = await searchRes . text ( ) ;
console . warn ( '[Playlist] Spotify search failed:' , searchRes . status , errText . substring ( 0 , 150 ) ) ;
console . warn (
break ;
"Spotify search failed:" ,
searchRes . status ,
errText . substring ( 0 , 150 )
) ;
console . log (
"TRACK_SEARCH_RESPONSE_BODY_IF_FAILED:" ,
errText . substring ( 0 , 300 )
) ;
searchRequestsCount ++ ;
queryIndex ++ ;
continue ;
}
}
const searchData = ( await safeParseJson ( searchRes , "SearchTracks" ) ) as any ;
const searchData = ( await safeParseJson ( searchRes , 'SearchTracks' ) ) as any ;
const rawTracks : SpotifySearchTrack [ ] = Array . isArray ( searchData ? . tracks ? . items )
const rawTracks : SpotifySearchTrack [ ] = Array . isArray ( searchData ? . tracks ? . items )
? ( searchData . tracks . items as SpotifySearchTrack [ ] )
? searchData . tracks . items : [ ] ;
: [ ] ;
console . log ( "TRACKS_RAW_FOUND_COUNT:" , rawTracks . length ) ;
totalRawResultsCount += rawTracks . length ;
console . log ( ` [Playlist] Query " ${ query } " offset ${ offset } : ${ rawTracks . length } raw tracks ` ) ;
const shuffledT racks = shuffleArray < SpotifySearchTrack > ( rawTracks ) ;
for ( const t rack of rawTracks ) {
let tracksAfterFilter = 0 ;
for ( const track of shuffledTracks ) {
if ( selectedTracks . length >= MAX_TRACKS ) break ;
if ( accumulatedDurationMs >= tripDurationMs ) break ;
if ( accumulatedDurationMs >= tripDurationMs ) break ;
const trackId = track . id ;
const trackId = track . id ;
const trackUri = track . uri ;
const trackUri = track . uri ;
const trackDurationMs = track . duration_ms ;
const trackDurationMs = track . duration_ms ;
if ( ! trackId ) continue ;
if ( ! trackId || ! trackUri || ! trackDurationMs ) continue ;
if ( ! trackUri ) continue ;
if ( ! trackDurationMs ) continue ;
if ( track . is_local === true ) continue ;
if ( track . is_local === true ) continue ;
if ( track . is_playable === false ) continue ;
if ( track . is_playable === false ) continue ;
if ( selectedTrackIds . has ( trackId ) ) continue ;
if ( selectedTrackIds . has ( trackId ) ) continue ;
if ( validateGenre && hasGenre ) {
const mainArtistId = track . artists ? . [ 0 ] ? . id ;
if ( ! mainArtistId ) { tracksRejectedCount ++ ; continue ; }
const artistGenres = await getArtistGenres ( mainArtistId , providerToken ) ;
if ( ! genreMatches ( artistGenres , cleanGenre ) ) {
console . log ( ` [Playlist] REJECTED: " ${ track . artists ? . [ 0 ] ? . name } " genres=[ ${ artistGenres . join ( ', ' ) } ] ` ) ;
tracksRejectedCount ++ ;
continue ;
}
}
const artistKey = getMainArtistKey ( track ) ;
const artistKey = getMainArtistKey ( track ) ;
const currentArtistCount = artistCount . get ( artistKey ) ? ? 0 ;
if ( ( artistCount . get ( artistKey ) ? ? 0 ) >= MAX_TRACKS_PER_ARTIST ) continue ;
if ( currentArtistCount >= MAX_TRACKS_PER_ARTIST ) continue ;
results . push ( { id : trackId , uri : trackUri , duration_ms : trackDurationMs } ) ;
}
selectedTracks . push ( {
// Don't paginate if we already have plenty of tracks from this query
id : trackId ,
if ( rawTracks . length < 20 ) break ;
uri : trackUri ,
}
duration_ms : trackDurationM s ,
return result s;
} ;
// D. Create empty Spotify playlist
const createPlaylistUrl = 'https://api.spotify.com/v1/me/playlists' ;
const createPlaylistBody = JSON . stringify ( {
name : tripName ,
description : ` Roadtrip from ${ origin } to ${ destination } ${ cleanGenre ? ` · ${ cleanGenre } vibes ` : '' } . ` ,
public : false ,
} ) ;
} ) ;
selectedTrackIds . add ( trackId ) ;
console . log ( 'CREATE_PLAYLIST_URL:' , createPlaylistUrl ) ;
artistCount . set ( artistKey , currentArtistCount + 1 ) ;
accumulatedDurationM s + = trackDurationMs ;
const createPlaylistRe s = await fetch ( createPlaylistUrl , {
tracksAfterFilter ++ ;
method : 'POST' ,
headers : {
Authorization : ` Bearer ${ providerToken } ` ,
'Content-Type' : 'application/json' ,
} ,
body : createPlaylistBody ,
} ) ;
console . log ( 'CREATE_PLAYLIST_HTTP_STATUS:' , createPlaylistRes . status ) ;
const createPlaylistResText = await createPlaylistRes . text ( ) ;
if ( ! createPlaylistRes . ok ) {
console . log ( 'CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:' , createPlaylistResText . substring ( 0 , 300 ) ) ;
if ( createPlaylistRes . status === 403 ) {
await clearSpotifyTokens ( ) ;
console . warn ( 'CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect.' ) ;
Alert . alert ( 'Permissão Spotify Necessária' , 'Reconecta o Spotify para dar permissão de criar playlists.' ) ;
playlistCreationFailed = true ;
playlistFailureReason = 'scope' ;
throw new Error ( 'CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required.' ) ;
}
}
console . log ( "TRACKS_AFTER_FILTER_COUNT:" , tracksAfterFilter ) ;
throw new Error (
console . log ( "TRACKS_SELECTED_COUNT:" , selectedTracks . length ) ;
` Spotify API returned status ${ createPlaylistRes . status } for CreatePlaylist: ${ createPlaylistResText . substring ( 0 , 150 ) } `
console . log ( "UNIQUE_ARTISTS_COUNT:" , artistCount . size ) ;
) ;
console . log ( "SELECTED_TRACKS_TOTAL_DURATION_MS:" , accumulatedDurationMs ) ;
searchRequestsCount ++ ;
queryIndex ++ ;
}
}
console . log ( "TRACKS_SELECTED_COUNT:" , selectedTracks . length ) ;
let playlistData : any ;
console . log ( "SELECTED_TRACKS_TOTAL_DURATION_MS:" , accumulatedDurationMs ) ;
try {
playlistData = JSON . parse ( createPlaylistResText ) ;
} catch {
throw new Error ( ` Failed to parse JSON for CreatePlaylist: ${ createPlaylistResText . substring ( 0 , 150 ) } ` ) ;
}
if ( ! playlistData . id ) throw new Error ( 'Could not create playlist' ) ;
const playlistId = playlistData . id ;
generatedPlaylistUrl = playlistData . external_urls . spotify ;
console . log ( 'TARGET_PLAYLIST_DURATION_MS:' , tripDurationMs ) ;
// ─── PHASE 1: Collect style tracks (80% of target, genre-validated) ─────────
const selectedTracks : SelectedSpotifyTrack [ ] = [ ] ;
const allStyleQueries = [ . . . styleQueries , . . . aiArtistQueries ] ;
console . log ( '[Playlist] Phase 1 style queries:' , allStyleQueries ) ;
for ( const query of allStyleQueries ) {
if ( selectedTracks . length >= styleTargetCount ) break ;
if ( accumulatedDurationMs >= tripDurationMs ) break ;
if ( searchRequestsCount >= 60 ) break ;
const tracks = await searchAndFilter ( query , true ) ;
for ( const t of tracks ) {
if ( selectedTracks . length >= styleTargetCount ) break ;
if ( accumulatedDurationMs >= tripDurationMs ) break ;
if ( selectedTrackIds . has ( t . id ) ) continue ;
const artistKey = getMainArtistKey ( t as any ) ;
if ( ( artistCount . get ( artistKey ) ? ? 0 ) >= MAX_TRACKS_PER_ARTIST ) continue ;
selectedTracks . push ( t ) ;
selectedTrackIds . add ( t . id ) ;
artistCount . set ( artistKey , ( artistCount . get ( artistKey ) ? ? 0 ) + 1 ) ;
accumulatedDurationMs += t . duration_ms ;
}
}
console . log ( '[Playlist] After Phase 1 – styleTracks count:' , selectedTracks . length , '| duration (ms):' , accumulatedDurationMs ) ;
// ─── PHASE 2: Fill remaining duration with roadtrip filler (no genre check) ─
if ( ! hasGenre && accumulatedDurationMs < tripDurationMs ) {
const fillerQueries = [ . . . aiArtistQueries , . . . genericFillerQueries ] ;
console . log ( '[Playlist] Phase 2 filler queries:' , fillerQueries ) ;
for ( const query of fillerQueries ) {
if ( accumulatedDurationMs >= tripDurationMs ) break ;
if ( searchRequestsCount >= 60 ) break ;
const tracks = await searchAndFilter ( query , false ) ;
for ( const t of tracks ) {
if ( accumulatedDurationMs >= tripDurationMs ) break ;
if ( selectedTrackIds . has ( t . id ) ) continue ;
const artistKey = getMainArtistKey ( t as any ) ;
if ( ( artistCount . get ( artistKey ) ? ? 0 ) >= MAX_TRACKS_PER_ARTIST ) continue ;
selectedTracks . push ( t ) ;
selectedTrackIds . add ( t . id ) ;
artistCount . set ( artistKey , ( artistCount . get ( artistKey ) ? ? 0 ) + 1 ) ;
accumulatedDurationMs += t . duration_ms ;
}
}
console . log ( '[Playlist] After Phase 2 – total tracks:' , selectedTracks . length , '| duration (ms):' , accumulatedDurationMs ) ;
}
console . log ( '[Playlist] finalTracks:' , selectedTracks . length ) ;
console . log ( '[SpotifyPlaylistsDebug] Favorite Genre Received:' , favoriteGenre || '(empty)' ) ;
console . log ( '[SpotifyPlaylistsDebug] Normalized Genre:' , cleanGenre || '(empty)' ) ;
console . log ( '[SpotifyPlaylistsDebug] Target Duration (ms):' , tripDurationMs ) ;
console . log ( '[SpotifyPlaylistsDebug] Raw Tracks Found (Accumulated):' , totalRawResultsCount ) ;
console . log ( '[SpotifyPlaylistsDebug] Tracks Rejected by Genre:' , tracksRejectedCount ) ;
console . log ( '[SpotifyPlaylistsDebug] Final Tracks Added:' , selectedTracks . length ) ;
console . log ( '[SpotifyPlaylistsDebug] Final Playlist Duration (ms):' , accumulatedDurationMs ) ;
if ( selectedTracks . length > 0 ) {
if ( selectedTracks . length > 0 ) {
// F. Add tracks to playlist in chunks
// F. Add tracks to playlist in chunks
@@ -655,6 +715,14 @@ Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie
playlistCreationFailed = false ;
playlistCreationFailed = false ;
if ( hasGenre ) {
// Accept a tolerance of about 5-10 minutes (300,000 to 600,000 ms) below trip duration
if ( accumulatedDurationMs < tripDurationMs - 300000 ) {
playlistSuccessMessage = "A playlist foi criada apenas com músicas do estilo escolhido, mas ficou mais curta porque não foram encontradas músicas suficientes." ;
} else {
playlistSuccessMessage = "Viagem e playlist criadas com sucesso apenas com músicas do estilo escolhido!" ;
}
} else {
if (
if (
accumulatedDurationMs < tripDurationMs - 60000 &&
accumulatedDurationMs < tripDurationMs - 60000 &&
( selectedTracks . length >= MAX_TRACKS ||
( selectedTracks . length >= MAX_TRACKS ||
@@ -670,6 +738,7 @@ Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie
}
}
}
}
}
}
}
} else {
} else {
console . warn ( "No tracks found for queries:" , searchQueries ) ;
console . warn ( "No tracks found for queries:" , searchQueries ) ;
@@ -706,12 +775,20 @@ Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie
} else {
} else {
if ( playlistCreationFailed ) {
if ( playlistCreationFailed ) {
if ( playlistFailureReason === 'notracks' ) {
if ( playlistFailureReason === 'notracks' ) {
if ( hasGenre ) {
Alert . alert ( 'Aviso' , 'Não foi possível encontrar músicas suficientes desse estilo. Tenta outro estilo musical.' ) ;
} else {
Alert . alert ( 'Sucesso!' , 'Playlist criada, mas não foram encontradas músicas para adicionar.' ) ;
Alert . alert ( 'Sucesso!' , 'Playlist criada, mas não foram encontradas músicas para adicionar.' ) ;
}
} else {
} else {
Alert . alert ( 'Sucesso!' , 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.' ) ;
Alert . alert ( 'Sucesso!' , 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.' ) ;
}
}
} else if ( generatedPlaylistUrl ) {
} else if ( generatedPlaylistUrl ) {
if ( hasGenre && accumulatedDurationMs < tripDurationMs - 300000 ) {
Alert . alert ( 'Aviso' , playlistSuccessMessage ) ;
} else {
Alert . alert ( 'Sucesso!' , playlistSuccessMessage ) ;
Alert . alert ( 'Sucesso!' , playlistSuccessMessage ) ;
}
} else {
} else {
Alert . alert ( 'Sucesso!' , 'Viagem calculada e guardada!' ) ;
Alert . alert ( 'Sucesso!' , 'Viagem calculada e guardada!' ) ;
}
}