WIP playlist style fix
This commit is contained in:
@@ -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 ${origin} to ${destination}.
|
hasGenre = Boolean(favoriteGenre && favoriteGenre.trim().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
|
aiArtistQueries = 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>();
|
||||||
searchRequestsCount < MAX_SEARCH_REQUESTS &&
|
const artistCount = 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 shuffledTracks = shuffleArray<SpotifySearchTrack>(rawTracks);
|
for (const track 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: trackDurationMs,
|
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);
|
console.log('CREATE_PLAYLIST_URL:', createPlaylistUrl);
|
||||||
artistCount.set(artistKey, currentArtistCount + 1);
|
|
||||||
|
|
||||||
accumulatedDurationMs += trackDurationMs;
|
const createPlaylistRes = 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!');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user