diff --git a/.gitignore b/.gitignore index d914c32..6a83495 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.* # generated native folders /ios /android + +.env +.env.local diff --git a/src/auth/spotifyToken.ts b/src/auth/spotifyToken.ts index e3735e3..40db9e9 100644 --- a/src/auth/spotifyToken.ts +++ b/src/auth/spotifyToken.ts @@ -79,6 +79,7 @@ export async function refreshSpotifyToken() { const data = await res.json(); if (data.access_token) { console.log('[SpotifyToken] Spotify access token refreshed successfully!'); + console.log('[SpotifyToken] REFRESH_GRANTED_SCOPE:', data.scope ?? '(no scope field returned)'); await setSpotifyToken(data.access_token); if (data.refresh_token) { await setSpotifyRefreshToken(data.refresh_token); diff --git a/src/screens/trip/NewTripScreen.tsx b/src/screens/trip/NewTripScreen.tsx index 7b54458..749d1c1 100644 --- a/src/screens/trip/NewTripScreen.tsx +++ b/src/screens/trip/NewTripScreen.tsx @@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { X, MapPin, ArrowRight, Navigation } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { supabase } from '../../services/supabase'; -import { getSpotifyAccessToken, refreshSpotifyToken } from '../../auth/spotifyToken'; +import { getSpotifyAccessToken, refreshSpotifyToken, clearSpotifyTokens } from '../../auth/spotifyToken'; import { OLLAMA_API_URL } from '../../services/ollama'; // @ts-ignore @@ -45,7 +45,9 @@ export default function NewTripScreen({ navigation }) { setDuration(finalDuration); let generatedPlaylistUrl = null; - let spotifyPremiumRequired = false; + let playlistCreationFailed = false; + let playlistFailureReason = ''; + let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.'; try { console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName); @@ -79,13 +81,12 @@ export default function NewTripScreen({ navigation }) { console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!providerToken); if (providerToken) { - // Proactively check if token is valid, or refresh it - console.log("Validating Spotify token via GET /v1/me..."); - let testRes = await fetch('https://api.spotify.com/v1/me', { + // Validate token via GET /v1/me (free endpoint, no Premium required) + let meRes = await fetch('https://api.spotify.com/v1/me', { headers: { Authorization: `Bearer ${providerToken}` } }); - if (testRes.status === 401) { + if (meRes.status === 401) { console.log("Spotify token is invalid/expired (401), attempting to refresh..."); const newToken = await refreshSpotifyToken(); if (newToken) { @@ -95,22 +96,22 @@ export default function NewTripScreen({ navigation }) { console.log("Failed to refresh Spotify token."); providerToken = null; } - } else if (!testRes.ok) { - const testErr = await testRes.text(); - console.warn("Spotify validation request failed on GET /v1/me:", testRes.status, testErr); + } else if (!meRes.ok) { + const meErr = await meRes.text(); + console.warn("Spotify GET /v1/me failed:", meRes.status, meErr); providerToken = null; } else { - console.log("Spotify token is valid (GET /v1/me returned 200 OK)."); + console.log("Spotify token valid (GET /v1/me returned 200 OK)."); } } if (!providerToken) { console.log("Spotify token missing or expired, skipping playlist generation."); - if (!spotifyPremiumRequired) { - Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.'); - } + playlistCreationFailed = true; + playlistFailureReason = 'token'; + Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.'); } else { - // B. Fetch Spotify User ID + // B. Fetch Spotify User ID (reuse /v1/me — already validated above) const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', { headers: { 'Authorization': `Bearer ${providerToken}`, @@ -118,21 +119,24 @@ export default function NewTripScreen({ navigation }) { } }); const spotifyUserData = await safeParseJson(spotifyUserRes, 'SpotifyUser'); - if (!spotifyUserData.id) throw new Error('Could not fetch Spotify User ID'); - const spotifyUserId = spotifyUserData.id; + const spotifyUserId = spotifyUserData?.id ?? null; + const spotifyUserCountry = spotifyUserData?.country || 'PT'; + console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId); + if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me'); // C. Call Ollama server + const ollamaPrompt = `I am taking a roadtrip from ${origin} to ${destination}. The trip is called "${tripName}" and takes about ${duration}. Reply ONLY with a JSON array of up to 10 Spotify search queries (e.g. genres, moods, or themes) that fit this journey. Example: ["portuguese pop", "italian road trip", "european indie", "summer travel songs"]. No other text.`; const ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "qwen3-coder:30b", - messages: [{ "role": "user", "content": `I am taking a roadtrip from ${origin} to ${destination}. Reply ONLY with 3 Spotify genre seeds separated by commas (e.g., pop,rock,indie) that fit this journey. No other text.` }], + messages: [{ "role": "user", "content": ollamaPrompt }], stream: false }) }); - let seed_genres = "pop,road-trip,happy"; // Fallback genres + let searchQueries: string[] = ["pop hits", "road trip songs", "top hits Portugal", "summer hits", "travel songs"]; // Fallback try { const ollamaData = await safeParseJson(ollamaRes, 'Ollama'); let rawAiText = ollamaData?.message?.content || ""; @@ -140,39 +144,57 @@ export default function NewTripScreen({ navigation }) { // Clean AI text rawAiText = rawAiText.replace(/```json/g, '').replace(/```/g, '').trim(); - if (rawAiText.length > 0 && !rawAiText.toLowerCase().startsWith("a ")) { - seed_genres = rawAiText.replace(/\s+/g, '').toLowerCase(); - // Spotify limits to 5 seed genres, let's keep it clean - seed_genres = seed_genres.split(',').slice(0, 5).join(','); + if (rawAiText.length > 0 && rawAiText.startsWith("[")) { + const parsed = JSON.parse(rawAiText); + if (Array.isArray(parsed) && parsed.length > 0) { + const aiQueries = parsed.map(String).slice(0, 10); + searchQueries = [...aiQueries, ...searchQueries]; + } else { + console.log("Ollama returned empty array, using fallbacks"); + } } else { - console.log("AI returned plain text/error, using fallback genres:", rawAiText); + console.log("AI returned plain text/error, using fallback queries:", rawAiText); } } catch (aiError) { - console.log("AI parsing failed, using fallback genres.", aiError); + console.log("AI parsing failed, using fallback queries.", aiError); } // D. Create empty playlist - console.log("[SpotifyPlaylistDebug] playlist create request started"); - console.log("[SpotifyPlaylistDebug] userId exists:", Boolean(spotifyUserId)); - console.log("[SpotifyPlaylistDebug] access token exists:", Boolean(providerToken)); + const createPlaylistUrl = 'https://api.spotify.com/v1/me/playlists'; + const createPlaylistBody = JSON.stringify({ + name: tripName, + description: `Roadtrip from ${origin} to ${destination}. Themes: ${searchQueries.join(', ')}`, + public: false + }); + console.log("CREATE_PLAYLIST_URL:", createPlaylistUrl); + console.log("CREATE_PLAYLIST_BODY:", createPlaylistBody); - const createPlaylistRes = await fetch(`https://api.spotify.com/v1/users/${spotifyUserId}/playlists`, { + const createPlaylistRes = await fetch(createPlaylistUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${providerToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: tripName, - description: `Roadtrip from ${origin} to ${destination}. Genres: ${seed_genres}`, - public: false - }) + body: createPlaylistBody }); - console.log("[SpotifyPlaylistDebug] create playlist HTTP status:", createPlaylistRes.status); + console.log("CREATE_PLAYLIST_HTTP_STATUS:", createPlaylistRes.status); const createPlaylistResText = await createPlaylistRes.text(); if (!createPlaylistRes.ok) { - console.log("[SpotifyPlaylistDebug] create playlist response body if failed:", createPlaylistResText); + console.log("CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:", createPlaylistResText.substring(0, 300)); + if (createPlaylistRes.status === 403) { + // Stored refresh token predates playlist scopes — clear tokens so next login forces full re-auth + await clearSpotifyTokens(); + console.warn("CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect."); + Alert.alert( + 'Permissão Spotify Necessária', + 'Reconnect Spotify to grant playlist permissions. Go to Profile and log in with Spotify again.' + ); + playlistCreationFailed = true; + playlistFailureReason = 'scope'; + // Skip further playlist work — trip will still be saved + 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)}`); } @@ -187,63 +209,102 @@ export default function NewTripScreen({ navigation }) { const playlistId = playlistData.id; generatedPlaylistUrl = playlistData.external_urls.spotify; - // E. Fetch Spotify track recommendations via Search (does not require Premium) + // E. Fill playlist with tracks based on duration + console.log("TARGET_PLAYLIST_DURATION_MS:", tripDurationMs); let accumulatedDurationMs = 0; - let trackUris: string[] = []; - let attempts = 0; - const genresList = seed_genres.split(','); - let genreIndex = 0; + let selectedTracks: { id: string; uri: string; duration_ms: number }[] = []; + let searchRequestsCount = 0; + const MAX_SEARCH_REQUESTS = 40; + const MAX_TRACKS = 400; + let queryIndex = 0; + let offset = 0; + let noMoreTracks = false; - while (accumulatedDurationMs < tripDurationMs && attempts < 10) { - const currentGenre = genresList[genreIndex % genresList.length] || 'pop'; - const query = encodeURIComponent(`genre:${currentGenre}`); - const searchRes = await fetch(`https://api.spotify.com/v1/search?type=track&q=${query}&limit=50&offset=${attempts * 50}`, { + while ( + accumulatedDurationMs < tripDurationMs && + searchRequestsCount < MAX_SEARCH_REQUESTS && + selectedTracks.length < MAX_TRACKS && + !noMoreTracks + ) { + const currentQuery = searchQueries[queryIndex % searchQueries.length]; + const queryEncoded = encodeURIComponent(currentQuery); + const searchUrl = `https://api.spotify.com/v1/search?type=track&q=${queryEncoded}&limit=10&market=${spotifyUserCountry}&offset=${offset}`; + + console.log("TRACK_SEARCH_QUERY:", currentQuery, "offset:", offset); + console.log("TRACK_SEARCH_URL:", searchUrl); + + const searchRes = await fetch(searchUrl, { headers: { 'Authorization': `Bearer ${providerToken}`, 'Content-Type': 'application/json' } }); + console.log("TRACK_SEARCH_STATUS:", searchRes.status); + if (!searchRes.ok) { const errText = await searchRes.text(); - if (searchRes.status === 403 || errText.toLowerCase().includes('active premium subscription required')) { - spotifyPremiumRequired = true; - console.warn("Spotify search skipped due to expected Premium limitation:", searchRes.status, errText); - } else { - console.warn("Search failed:", errText); - Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`); - } - break; + console.warn("Spotify search failed:", searchRes.status, errText.substring(0, 150)); + console.log("TRACK_SEARCH_RESPONSE_BODY_IF_FAILED:", errText.substring(0, 300)); + queryIndex++; + offset = 0; + searchRequestsCount++; + continue; } const searchData = await safeParseJson(searchRes, 'SearchTracks'); - const tracks = searchData?.tracks?.items; + const tracks = searchData?.tracks?.items || []; + console.log("TRACKS_RAW_FOUND_COUNT:", tracks.length); - if (!tracks || tracks.length === 0) { - genreIndex++; - attempts++; + if (tracks.length === 0) { + queryIndex++; + offset = 0; + if (queryIndex >= searchQueries.length * 3) { + noMoreTracks = true; + } + searchRequestsCount++; continue; } + let tracksAfterFilter = 0; + for (const track of tracks) { - if (!trackUris.includes(track.uri)) { - trackUris.push(track.uri); - accumulatedDurationMs += track.duration_ms; - if (accumulatedDurationMs >= tripDurationMs) break; + if (selectedTracks.length >= MAX_TRACKS) break; + if (accumulatedDurationMs >= tripDurationMs) break; + + if (track.id && track.uri && track.duration_ms && track.is_local !== true) { + if (track.is_playable === undefined || track.is_playable !== false) { + if (!selectedTracks.some(t => t.id === track.id)) { + selectedTracks.push({ id: track.id, uri: track.uri, duration_ms: track.duration_ms }); + accumulatedDurationMs += track.duration_ms; + tracksAfterFilter++; + } + } } } - attempts++; - genreIndex++; + + console.log("TRACKS_AFTER_FILTER_COUNT:", tracksAfterFilter); + + offset += 10; + if (offset >= 1000) { + queryIndex++; + offset = 0; + } + searchRequestsCount++; } - if (trackUris.length > 0) { - // F. Add tracks to playlist + console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length); + console.log("SELECTED_TRACKS_TOTAL_DURATION_MS:", accumulatedDurationMs); + + if (selectedTracks.length > 0) { + // F. Add tracks to playlist in chunks + const trackUris = selectedTracks.map(t => t.uri); const chunkSize = 100; + let tracksAddedSuccessfully = true; for (let i = 0; i < trackUris.length; i += chunkSize) { const chunk = trackUris.slice(i, i + chunkSize); - console.log("[SpotifyPlaylistDebug] Add tracks request started for chunk:", i / chunkSize + 1); - const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { + const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, { method: 'POST', headers: { 'Authorization': `Bearer ${providerToken}`, @@ -252,32 +313,41 @@ export default function NewTripScreen({ navigation }) { body: JSON.stringify({ uris: chunk }) }); - console.log("[SpotifyPlaylistDebug] add tracks HTTP status:", addTracksRes.status); + console.log("ADD_TRACKS_HTTP_STATUS:", addTracksRes.status); if (!addTracksRes.ok) { const addTracksErr = await addTracksRes.text(); - console.log("[SpotifyPlaylistDebug] add tracks response body if failed:", addTracksErr); - if (addTracksRes.status === 403 || addTracksErr.toLowerCase().includes('active premium subscription required')) { - spotifyPremiumRequired = true; - console.warn("Spotify add tracks skipped due to expected Premium limitation:", addTracksRes.status, addTracksErr); - break; - } + console.log("ADD_TRACKS_RESPONSE_BODY_IF_FAILED:", addTracksErr.substring(0, 300)); + tracksAddedSuccessfully = false; throw new Error(`Spotify API returned status ${addTracksRes.status} while adding tracks: ${addTracksErr.substring(0, 150)}`); } } - console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl); + + if (tracksAddedSuccessfully) { + console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl); + playlistCreationFailed = false; + + if (accumulatedDurationMs < tripDurationMs - 60000 && (selectedTracks.length >= MAX_TRACKS || searchRequestsCount >= MAX_SEARCH_REQUESTS || noMoreTracks)) { + const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10; + if (hours >= 1) { + playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`; + } else { + const minutes = Math.round(accumulatedDurationMs / 60000); + playlistSuccessMessage = `Viagem guardada! Playlist criada com ${minutes} minutos de música.`; + } + } + } } else { - console.warn("No tracks found for genres:", seed_genres); + console.warn("No tracks found for queries:", searchQueries); + playlistCreationFailed = true; + playlistFailureReason = 'notracks'; } } } catch (playlistError: any) { const playlistErrorMessage = String(playlistError?.message || playlistError || ''); - if (playlistErrorMessage.includes('403') || playlistErrorMessage.toLowerCase().includes('active premium subscription required')) { - spotifyPremiumRequired = true; - console.warn("Spotify playlist skipped due to expected Premium limitation:", playlistErrorMessage); - } else { - console.warn("Expected failure generating playlist:", playlistErrorMessage); - Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistErrorMessage.substring(0, 50) || 'Erro Desconhecido'}`); - } + playlistCreationFailed = true; + playlistFailureReason = 'error'; + console.warn("Playlist generation failed:", playlistErrorMessage); + Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistErrorMessage.substring(0, 80) || 'Erro Desconhecido'}`); } // G. Save to Supabase unconditionally if route is valid @@ -299,12 +369,17 @@ export default function NewTripScreen({ navigation }) { console.error("DB Insert error:", dbError); Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message); } else { - Alert.alert( - 'Sucesso!', - spotifyPremiumRequired - ? 'Viagem criada, mas a playlist do Spotify não pôde ser criada porque a app Spotify precisa de Premium.' - : 'Viagem calculada e guardada!' - ); + if (playlistCreationFailed) { + if (playlistFailureReason === 'notracks') { + 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) { + Alert.alert('Sucesso!', playlistSuccessMessage); + } else { + Alert.alert('Sucesso!', 'Viagem calculada e guardada!'); + } navigation.goBack(); } } catch (dbEx) {