WIP playlist style fix
This commit is contained in:
@@ -84,6 +84,62 @@ function getRouteWaypoints(
|
||||
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
|
||||
@@ -127,6 +183,8 @@ export default function NewTripScreen({ navigation }) {
|
||||
let playlistCreationFailed = false;
|
||||
let playlistFailureReason = '';
|
||||
let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.';
|
||||
let hasGenre = false;
|
||||
let accumulatedDurationMs = 0;
|
||||
|
||||
try {
|
||||
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
|
||||
@@ -304,311 +362,313 @@ export default function NewTripScreen({ navigation }) {
|
||||
|
||||
console.log("PLAYLIST_FAVORITE_GENRE_USED:", favoriteGenre || "none");
|
||||
|
||||
const ollamaPrompt = `I am taking a roadtrip from ${origin} to ${destination}.
|
||||
The trip is called "${tripName}" and takes about ${duration}.
|
||||
The user's favorite music genre is: "${favoriteGenre || "not set"}".
|
||||
hasGenre = Boolean(favoriteGenre && favoriteGenre.trim().length > 0);
|
||||
const cleanGenre = hasGenre ? favoriteGenre.trim().toLowerCase() : "";
|
||||
|
||||
Reply ONLY with a JSON array of up to 10 Spotify search queries.
|
||||
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"].`;
|
||||
console.log('[Playlist] favoriteMusicStyle:', favoriteGenre || '(empty)');
|
||||
|
||||
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 {
|
||||
const ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: "qwen3-coder:30b",
|
||||
messages: [{ role: "user", content: ollamaPrompt }],
|
||||
model: 'qwen3-coder:30b',
|
||||
messages: [{ role: 'user', content: ollamaPrompt }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const ollamaData = await safeParseJson(ollamaRes, "Ollama");
|
||||
let rawAiText = ollamaData?.message?.content || "";
|
||||
|
||||
rawAiText = rawAiText
|
||||
.replace(/```json/g, "")
|
||||
.replace(/```/g, "")
|
||||
.trim();
|
||||
|
||||
if (rawAiText.length > 0 && rawAiText.startsWith("[")) {
|
||||
const ollamaData = await safeParseJson(ollamaRes, 'Ollama');
|
||||
let rawAiText = (ollamaData?.message?.content || '')
|
||||
.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
if (rawAiText.startsWith('[')) {
|
||||
const parsed = JSON.parse(rawAiText);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
aiQueries = parsed
|
||||
.map(String)
|
||||
.map(cleanSearchQuery)
|
||||
.filter(Boolean)
|
||||
.slice(0, 10);
|
||||
aiArtistQueries = parsed.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 {
|
||||
throw new Error(
|
||||
`Failed to parse JSON response for CreatePlaylist: ${createPlaylistResText.substring(
|
||||
0,
|
||||
150
|
||||
)}`
|
||||
);
|
||||
console.log('[Playlist] AI query generation failed; continuing without it.');
|
||||
}
|
||||
|
||||
if (!playlistData.id) {
|
||||
throw new Error("Could not create playlist");
|
||||
}
|
||||
// Generic roadtrip filler queries (only used in Phase 2 when no genre, or to pad when genre exhausted)
|
||||
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;
|
||||
generatedPlaylistUrl = playlistData.external_urls.spotify;
|
||||
// ── Target track count (based on trip duration, ~4 min per song)
|
||||
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("TARGET_PLAYLIST_DURATION_MS:", tripDurationMs);
|
||||
console.log('[Playlist] targetTrackCount:', TARGET_TRACK_COUNT);
|
||||
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;
|
||||
|
||||
while (
|
||||
accumulatedDurationMs < tripDurationMs &&
|
||||
searchRequestsCount < MAX_SEARCH_REQUESTS &&
|
||||
selectedTracks.length < MAX_TRACKS &&
|
||||
searchQueries.length > 0
|
||||
) {
|
||||
const currentQuery = searchQueries[queryIndex % searchQueries.length];
|
||||
const queryEncoded = encodeURIComponent(currentQuery);
|
||||
const offset = getRandomSpotifyOffset();
|
||||
// Helper to run Spotify track search for a given query and return accepted tracks
|
||||
const selectedTrackIds = new Set<string>();
|
||||
const artistCount = new Map<string, number>();
|
||||
let totalRawResultsCount = 0;
|
||||
let tracksRejectedCount = 0;
|
||||
let searchRequestsCount = 0;
|
||||
|
||||
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 =
|
||||
`https://api.spotify.com/v1/search` +
|
||||
`?type=track` +
|
||||
`&q=${queryEncoded}` +
|
||||
`&limit=10` +
|
||||
`&market=${spotifyUserCountry}` +
|
||||
`&offset=${offset}`;
|
||||
`https://api.spotify.com/v1/search?type=track` +
|
||||
`&q=${queryEncoded}&limit=20&market=${spotifyUserCountry}&offset=${offset}`;
|
||||
|
||||
console.log("TRACK_SEARCH_QUERY:", currentQuery);
|
||||
console.log("TRACK_SEARCH_OFFSET:", offset);
|
||||
console.log("TRACK_SEARCH_URL:", searchUrl);
|
||||
console.log('TRACK_SEARCH_QUERY:', query, '| offset:', offset);
|
||||
|
||||
const searchRes = await fetch(searchUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${providerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { Authorization: `Bearer ${providerToken}`, 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
console.log("TRACK_SEARCH_STATUS:", searchRes.status);
|
||||
searchRequestsCount++;
|
||||
|
||||
if (!searchRes.ok) {
|
||||
const errText = await searchRes.text();
|
||||
|
||||
console.warn(
|
||||
"Spotify search failed:",
|
||||
searchRes.status,
|
||||
errText.substring(0, 150)
|
||||
);
|
||||
|
||||
console.log(
|
||||
"TRACK_SEARCH_RESPONSE_BODY_IF_FAILED:",
|
||||
errText.substring(0, 300)
|
||||
);
|
||||
|
||||
searchRequestsCount++;
|
||||
queryIndex++;
|
||||
continue;
|
||||
console.warn('[Playlist] Spotify search failed:', searchRes.status, errText.substring(0, 150));
|
||||
break;
|
||||
}
|
||||
const searchData = (await safeParseJson(searchRes, "SearchTracks")) as any;
|
||||
|
||||
const searchData = (await safeParseJson(searchRes, 'SearchTracks')) as any;
|
||||
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 shuffledTracks = shuffleArray<SpotifySearchTrack>(rawTracks);
|
||||
|
||||
let tracksAfterFilter = 0;
|
||||
|
||||
for (const track of shuffledTracks) {
|
||||
if (selectedTracks.length >= MAX_TRACKS) break;
|
||||
for (const track of rawTracks) {
|
||||
if (accumulatedDurationMs >= tripDurationMs) break;
|
||||
|
||||
const trackId = track.id;
|
||||
const trackUri = track.uri;
|
||||
const trackDurationMs = track.duration_ms;
|
||||
|
||||
if (!trackId) continue;
|
||||
if (!trackUri) continue;
|
||||
if (!trackDurationMs) continue;
|
||||
if (!trackId || !trackUri || !trackDurationMs) continue;
|
||||
if (track.is_local === true) continue;
|
||||
if (track.is_playable === false) 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 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({
|
||||
id: trackId,
|
||||
uri: trackUri,
|
||||
duration_ms: trackDurationMs,
|
||||
// Don't paginate if we already have plenty of tracks from this query
|
||||
if (rawTracks.length < 20) break;
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
// 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);
|
||||
artistCount.set(artistKey, currentArtistCount + 1);
|
||||
console.log('CREATE_PLAYLIST_URL:', createPlaylistUrl);
|
||||
|
||||
accumulatedDurationMs += trackDurationMs;
|
||||
tracksAfterFilter++;
|
||||
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.');
|
||||
}
|
||||
|
||||
console.log("TRACKS_AFTER_FILTER_COUNT:", tracksAfterFilter);
|
||||
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
|
||||
console.log("UNIQUE_ARTISTS_COUNT:", artistCount.size);
|
||||
console.log("SELECTED_TRACKS_TOTAL_DURATION_MS:", accumulatedDurationMs);
|
||||
|
||||
searchRequestsCount++;
|
||||
queryIndex++;
|
||||
throw new Error(
|
||||
`Spotify API returned status ${createPlaylistRes.status} for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
|
||||
console.log("SELECTED_TRACKS_TOTAL_DURATION_MS:", accumulatedDurationMs);
|
||||
let playlistData: any;
|
||||
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) {
|
||||
// F. Add tracks to playlist in chunks
|
||||
@@ -655,6 +715,14 @@ Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie
|
||||
|
||||
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 (
|
||||
accumulatedDurationMs < tripDurationMs - 60000 &&
|
||||
(selectedTracks.length >= MAX_TRACKS ||
|
||||
@@ -670,6 +738,7 @@ Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("No tracks found for queries:", searchQueries);
|
||||
|
||||
@@ -706,12 +775,20 @@ Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie
|
||||
} else {
|
||||
if (playlistCreationFailed) {
|
||||
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.');
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Sucesso!', 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.');
|
||||
}
|
||||
} else if (generatedPlaylistUrl) {
|
||||
if (hasGenre && accumulatedDurationMs < tripDurationMs - 300000) {
|
||||
Alert.alert('Aviso', playlistSuccessMessage);
|
||||
} else {
|
||||
Alert.alert('Sucesso!', playlistSuccessMessage);
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Sucesso!', 'Viagem calculada e guardada!');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user