Line data Source code
1 : // Filepath: Melodex/melodex-front-end/src/contexts/SongContext.jsx
2 1 : import React, { createContext, useState, useCallback, useContext, useEffect, useRef } from 'react';
3 1 : import { useUserContext } from './UserContext';
4 :
5 1 : const SongContext = createContext();
6 :
7 1 : export const useSongContext = () => {
8 251 : const context = useContext(SongContext);
9 251 : if (!context) throw new Error('useSongContext must be used within a SongProvider');
10 251 : return context;
11 251 : };
12 :
13 1 : export const SongProvider = ({ children }) => {
14 59 : const { userID } = useUserContext();
15 59 : console.log('SongProvider: userID from UserContext:', userID);
16 :
17 59 : const [songList, setSongList] = useState([]);
18 59 : const [songBuffer, setSongBuffer] = useState([]);
19 59 : const [currentPair, setCurrentPair] = useState([]);
20 59 : const [rankedSongs, setRankedSongs] = useState([]);
21 59 : const [loading, setLoading] = useState(false);
22 59 : const [mode, setMode] = useState('new');
23 59 : const [selectedGenre, setSelectedGenre] = useState('any');
24 :
25 : // These are controlled by /rank apply
26 59 : const [lastFilters, setLastFilters] = useState({ genre: 'any', subgenre: 'any', decade: 'all decades' });
27 59 : const [filtersApplied, setFiltersApplied] = useState(false);
28 :
29 59 : const [isBackgroundFetching, setIsBackgroundFetching] = useState(false);
30 59 : const [isRankPageActive, setIsRankPageActive] = useState(false);
31 59 : const [contextUserID, setContextUserID] = useState(null);
32 :
33 : // Guards/refs
34 59 : const inFlightRef = useRef(false);
35 59 : const lastBgFetchAtRef = useRef(0);
36 59 : const unmountedRef = useRef(false);
37 :
38 : // For accurate totals during bursts:
39 59 : const listLenRef = useRef(0);
40 59 : const bufferLenRef = useRef(0);
41 59 : useEffect(() => { listLenRef.current = songList.length; }, [songList.length]);
42 59 : useEffect(() => { bufferLenRef.current = songBuffer.length; }, [songBuffer.length]);
43 :
44 : // Prefetch tuning (feel free to tweak)
45 59 : const PREFETCH_TARGET = 30; // aim to keep list+buffer around this
46 59 : const LOW_WATERMARK = 10; // when list drops below this, refill from buffer
47 59 : const PREFETCH_COOLDOWN_MS = 6000;
48 59 : const MAX_PAGES_PER_BURST = 2; // smaller burst
49 59 : const MAX_BUFFER = 40; // hard cap on buffer size
50 :
51 59 : useEffect(() => {
52 12 : unmountedRef.current = false;
53 12 : return () => { unmountedRef.current = true; };
54 59 : }, []);
55 :
56 59 : useEffect(() => {
57 19 : console.log('SongProvider useEffect: Setting contextUserID to', userID);
58 19 : setContextUserID(userID);
59 59 : }, [userID]);
60 :
61 : // ---- Burst background prefetch (only /rank after filters applied) ----
62 59 : useEffect(() => {
63 24 : const runBurstPrefetch = async () => {
64 24 : const totalAvailableStart = songList.length + songBuffer.length;
65 24 : const now = Date.now();
66 24 : const cooledDown = now - lastBgFetchAtRef.current >= PREFETCH_COOLDOWN_MS;
67 :
68 24 : const canFetch =
69 24 : !!contextUserID &&
70 12 : mode === 'new' &&
71 12 : isRankPageActive &&
72 0 : filtersApplied &&
73 0 : cooledDown &&
74 0 : !inFlightRef.current &&
75 0 : !isBackgroundFetching &&
76 0 : totalAvailableStart > 0 && // <-- wait for *some* songs to exist (avoids overlap with initial fetch)
77 0 : totalAvailableStart < PREFETCH_TARGET;
78 :
79 24 : if (!canFetch) {
80 24 : console.log('Background fetch skipped:', {
81 24 : contextUserID,
82 24 : mode,
83 24 : isRankPageActive,
84 24 : filtersApplied,
85 24 : isBackgroundFetching,
86 24 : cooledDown,
87 24 : totalAvailable: totalAvailableStart,
88 24 : songListLength: songList.length,
89 24 : songBufferLength: songBuffer.length
90 24 : });
91 24 : return;
92 24 : }
93 :
94 0 : console.log('Triggering background fetch BURST (start totalAvailable:', totalAvailableStart, ')');
95 0 : inFlightRef.current = true;
96 0 : setIsBackgroundFetching(true);
97 0 : lastBgFetchAtRef.current = now;
98 :
99 0 : try {
100 0 : let pagesFetched = 0;
101 :
102 0 : while (pagesFetched < MAX_PAGES_PER_BURST) {
103 : // live totals from refs so we count changes during the burst
104 0 : const liveTotal = listLenRef.current + bufferLenRef.current;
105 0 : if (liveTotal >= PREFETCH_TARGET) break;
106 :
107 0 : const newSongs = await generateNewSongs(lastFilters, true);
108 0 : pagesFetched += 1;
109 :
110 0 : if (!newSongs || newSongs.length === 0) {
111 0 : console.warn('Burst prefetch: page returned 0 songs; stopping burst.');
112 0 : break;
113 0 : }
114 :
115 0 : setSongBuffer(prev => {
116 : // Append but enforce hard cap
117 0 : const updated = [...prev, ...newSongs];
118 0 : let final = updated;
119 0 : if (updated.length > MAX_BUFFER) {
120 0 : final = updated.slice(0, MAX_BUFFER);
121 0 : }
122 0 : console.log(`Burst page ${pagesFetched}: +${newSongs.length} songs -> buffer size now: ${final.length}`);
123 0 : return final;
124 0 : });
125 0 : }
126 24 : } catch (err) {
127 0 : console.error('Burst prefetch error:', err);
128 0 : } finally {
129 0 : inFlightRef.current = false;
130 0 : if (!unmountedRef.current) setIsBackgroundFetching(false);
131 0 : console.log('Background fetch BURST complete.');
132 0 : }
133 24 : };
134 :
135 24 : runBurstPrefetch();
136 59 : }, [
137 59 : songList.length, // only lengths (not arrays) to avoid identity churn
138 59 : songBuffer.length,
139 59 : contextUserID,
140 59 : mode,
141 59 : isRankPageActive,
142 59 : filtersApplied
143 59 : ]);
144 :
145 : // ---- Refill visible list from buffer when it gets low ----
146 59 : useEffect(() => {
147 24 : if (songList.length < LOW_WATERMARK && songBuffer.length > 0) {
148 0 : const want = LOW_WATERMARK * 2; // ~20 at a time
149 0 : const batchSize = Math.min(want, songBuffer.length);
150 0 : const newSongs = songBuffer.slice(0, batchSize);
151 0 : setSongList(prevList => [...prevList, ...newSongs]);
152 0 : setSongBuffer(prevBuffer => prevBuffer.slice(batchSize));
153 0 : console.log('Replenished songList from buffer:', newSongs.length, 'list size now:', (songList.length + newSongs.length));
154 0 : } else if (
155 24 : songList.length === 0 &&
156 24 : songBuffer.length === 0 &&
157 24 : mode === 'new' &&
158 24 : contextUserID &&
159 12 : isRankPageActive &&
160 0 : filtersApplied
161 24 : ) {
162 0 : console.log('Song list and buffer empty after filters; fetching initial songs');
163 0 : generateNewSongs(lastFilters).then(newSongs => {
164 0 : if (newSongs.length > 0) {
165 0 : setSongList(newSongs);
166 0 : console.log('Initial fetch (post-apply):', newSongs.length);
167 0 : } else {
168 0 : console.warn('Initial fetch returned no songs');
169 0 : }
170 0 : });
171 0 : }
172 59 : }, [songList.length, songBuffer.length, mode, contextUserID, lastFilters, isRankPageActive, filtersApplied]);
173 :
174 59 : const getNextPair = useCallback((songsToUse = songList) => {
175 0 : if (!Array.isArray(songsToUse)) {
176 0 : console.error('getNextPair: songsToUse is not an array', songsToUse);
177 0 : setCurrentPair([]);
178 0 : return;
179 0 : }
180 0 : const validSongs = songsToUse.filter(song => song && song.deezerID);
181 0 : console.log('getNextPair: Valid songs available:', validSongs.length, validSongs);
182 0 : if (validSongs.length < 2) {
183 0 : console.log('getNextPair: Not enough valid songs, currentPair set to empty');
184 0 : setCurrentPair([]);
185 0 : return;
186 0 : }
187 0 : const song1 = validSongs[0];
188 0 : const song2 = validSongs.find(song => String(song.deezerID) !== String(song1.deezerID));
189 0 : if (!song2) {
190 0 : console.error('getNextPair: Could not find a second song');
191 0 : setCurrentPair([]);
192 0 : return;
193 0 : }
194 0 : const newPair = [song1, song2];
195 0 : setCurrentPair(newPair);
196 0 : setSongList(validSongs.filter(song => String(song.deezerID) !== String(song1.deezerID) && String(song.deezerID) !== String(song2.deezerID)));
197 0 : console.log('getNextPair: New pair set:', newPair);
198 59 : }, [songList]);
199 :
200 59 : const generateNewSongs = async (filters = lastFilters ?? { genre: 'any', subgenre: 'any', decade: 'all decades' }, isBackground = false) => {
201 0 : if (!userID) return [];
202 0 : if (!isBackground) {
203 0 : setLoading(true);
204 0 : console.log('Loading set to true');
205 0 : }
206 0 : try {
207 0 : const controller = new AbortController();
208 0 : const timeoutId = setTimeout(() => {
209 0 : controller.abort();
210 0 : console.log('generateNewSongs fetch timed out');
211 0 : }, 30000);
212 :
213 0 : const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'}/user-songs/new`;
214 0 : console.log('generateNewSongs filters:', filters);
215 0 : const response = await fetch(url, {
216 0 : method: 'POST',
217 0 : body: JSON.stringify({ userID, ...filters }),
218 0 : headers: { 'Content-Type': 'application/json' },
219 0 : signal: controller.signal,
220 0 : });
221 :
222 0 : clearTimeout(timeoutId);
223 0 : if (!response.ok) {
224 0 : const errorText = await response.text();
225 0 : throw new Error(`Failed to fetch new songs: ${response.status} ${errorText}`);
226 0 : }
227 0 : const songs = await response.json();
228 0 : return songs;
229 0 : } catch (error) {
230 0 : console.error('Failed to generate new songs:', error);
231 0 : return [];
232 0 : } finally {
233 0 : if (!isBackground) {
234 0 : setLoading(false);
235 0 : console.log('Loading set to false');
236 0 : }
237 0 : }
238 0 : };
239 :
240 59 : const fetchReRankingData = async (genre = selectedGenre, subgenre = 'any', setContext = true) => {
241 0 : if (!contextUserID) {
242 0 : console.error('No userID available for fetchReRankingData');
243 0 : return [];
244 0 : }
245 0 : setLoading(true);
246 0 : console.log('Loading set to true');
247 0 : try {
248 0 : console.log('fetchReRankingData with genre:', genre, 'subgenre:', subgenre);
249 0 : const payload = { userID: contextUserID };
250 0 : if (subgenre !== 'any') {
251 0 : payload.subgenre = subgenre;
252 0 : if (genre !== 'any') payload.genre = genre;
253 0 : } else if (genre !== 'any') {
254 0 : payload.genre = genre;
255 0 : }
256 0 : console.log('fetchReRankingData payload:', payload);
257 0 : const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'}/user-songs/rerank`;
258 0 : const response = await fetch(url, {
259 0 : method: 'POST',
260 0 : headers: { 'Content-Type': 'application/json' },
261 0 : body: JSON.stringify(payload),
262 0 : });
263 0 : if (!response.ok) throw new Error('Failed to fetch re-ranking data');
264 0 : const reRankSongs = await response.json();
265 0 : console.log('fetchReRankingData: Retrieved songs:', reRankSongs);
266 0 : if (setContext) {
267 0 : setSongList(reRankSongs);
268 0 : getNextPair(reRankSongs);
269 0 : }
270 0 : return reRankSongs;
271 0 : } catch (error) {
272 0 : console.error('Failed to fetch re-ranking data:', error);
273 0 : return [];
274 0 : } finally {
275 0 : setLoading(false);
276 0 : console.log('Loading set to false');
277 0 : }
278 0 : };
279 :
280 59 : const fetchRankedSongs = useCallback(async ({ userID: fetchUserID, genre = selectedGenre, subgenre = 'any' }) => {
281 26 : const idToUse = fetchUserID || contextUserID;
282 26 : console.log('fetchRankedSongs called with userID:', idToUse, 'genre:', genre, 'subgenre:', subgenre);
283 26 : if (!idToUse) {
284 0 : console.error('No userID available for fetchRankedSongs');
285 0 : setRankedSongs([]);
286 0 : return [];
287 0 : }
288 26 : setLoading(true);
289 26 : console.log('Loading set to true');
290 26 : const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'}/user-songs/ranked`;
291 26 : console.log('Fetching ranked songs from:', url);
292 :
293 26 : try {
294 26 : const payload = { userID: idToUse };
295 26 : if (genre !== 'any') payload.genre = genre;
296 26 : if (subgenre !== 'any') payload.subgenre = subgenre;
297 :
298 26 : const response = await fetch(url, {
299 26 : method: 'POST',
300 26 : headers: { 'Content-Type': 'application/json' },
301 26 : body: JSON.stringify(payload),
302 26 : });
303 :
304 26 : if (!response.ok) throw new Error('Failed to fetch ranked songs');
305 :
306 26 : const text = await response.text();
307 26 : try {
308 26 : const ranked = JSON.parse(text);
309 26 : setRankedSongs(ranked);
310 26 : return ranked;
311 26 : } catch {
312 0 : setRankedSongs([]);
313 0 : return [];
314 0 : }
315 26 : } catch (error) {
316 0 : console.error('Failed to fetch ranked songs:', error);
317 0 : setRankedSongs([]);
318 0 : return [];
319 26 : } finally {
320 26 : setLoading(false);
321 26 : console.log('Loading set to false');
322 26 : }
323 59 : }, [contextUserID, selectedGenre]);
324 :
325 59 : const selectSong = async (winnerId, loserId, resetProcessing) => {
326 0 : if (!contextUserID) {
327 0 : console.error('No userID available for selectSong');
328 0 : resetProcessing?.();
329 0 : return;
330 0 : }
331 0 : setLoading(true);
332 0 : console.log('Loading set to true');
333 0 : try {
334 0 : const winnerSong = currentPair.find(s => s.deezerID.toString() === winnerId.toString());
335 0 : const loserSong = currentPair.find(s => s.deezerID.toString() === loserId.toString());
336 0 : if (!winnerSong || !loserSong) {
337 0 : console.error('Winner or loser song not found in currentPair', { winnerId, loserId, currentPair });
338 0 : resetProcessing?.();
339 0 : return;
340 0 : }
341 :
342 0 : const payload = {
343 0 : userID,
344 0 : deezerID: winnerSong.deezerID,
345 0 : opponentDeezerID: loserSong.deezerID,
346 0 : result: 'win',
347 0 : winnerSongName: winnerSong.songName,
348 0 : winnerArtist: winnerSong.artist,
349 0 : winnerGenre: winnerSong.genre,
350 0 : winnerSubgenre: winnerSong.subgenre || null,
351 0 : winnerDecade: winnerSong.decade || null,
352 0 : winnerAlbumCover: winnerSong.albumCover,
353 0 : winnerPreviewURL: winnerSong.previewURL,
354 0 : loserSongName: loserSong.songName,
355 0 : loserArtist: loserSong.artist,
356 0 : loserGenre: loserSong.genre,
357 0 : loserSubgenre: loserSong.subgenre || null,
358 0 : loserDecade: loserSong.decade || null,
359 0 : loserAlbumCover: loserSong.albumCover,
360 0 : loserPreviewURL: loserSong.previewURL,
361 0 : };
362 :
363 0 : const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'}/user-songs/upsert`;
364 0 : const response = await fetch(url, {
365 0 : method: 'POST',
366 0 : headers: { 'Content-Type': 'application/json' },
367 0 : body: JSON.stringify(payload),
368 0 : });
369 :
370 0 : if (!response.ok) throw new Error('Failed to update song ratings');
371 :
372 0 : const { newRatingA, newRatingB } = await response.json();
373 :
374 0 : const updatedList = songList.filter(song => String(song.deezerID) !== String(winnerId) && String(song.deezerID) !== String(loserId));
375 0 : setSongList(updatedList);
376 0 : setRankedSongs(prev => ([
377 0 : ...prev.filter(s =>
378 0 : String(s.deezerID) !== String(winnerSong.deezerID) &&
379 0 : String(s.deezerID) !== String(loserSong.deezerID)
380 0 : ),
381 0 : { ...winnerSong, ranking: newRatingA },
382 0 : { ...loserSong, ranking: newRatingB }
383 0 : ]));
384 :
385 0 : if (mode === 'new') {
386 0 : getNextPair(updatedList);
387 0 : } else if (mode === 'rerank') {
388 0 : const newPair = await fetchReRankingData();
389 0 : setCurrentPair(newPair.length >= 2 ? newPair : []);
390 0 : setSongList([]);
391 0 : }
392 0 : } catch (error) {
393 0 : console.error('Failed to select song:', error.message);
394 0 : } finally {
395 0 : setLoading(false);
396 0 : console.log('Loading set to false');
397 0 : resetProcessing?.();
398 0 : }
399 0 : };
400 :
401 59 : const skipSong = async (songId, resetProcessing) => {
402 0 : if (!contextUserID) {
403 0 : console.error('No userID available for skipSong');
404 0 : resetProcessing?.();
405 0 : return;
406 0 : }
407 0 : setLoading(true);
408 0 : console.log('Loading set to true');
409 0 : try {
410 0 : const skippedSong = currentPair.find(s => s.deezerID.toString() === songId.toString());
411 0 : const keptSong = currentPair.find(s => s.deezerID.toString() !== songId.toString());
412 0 : if (!skippedSong || !keptSong) {
413 0 : console.error('Skipped song or kept song not found in currentPair:', { songId, currentPair });
414 0 : resetProcessing?.();
415 0 : return;
416 0 : }
417 :
418 0 : const payload = {
419 0 : userID,
420 0 : deezerID: songId,
421 0 : ranking: null,
422 0 : skipped: true,
423 0 : songName: skippedSong.songName || 'Unknown Song',
424 0 : artist: skippedSong.artist || 'Unknown Artist',
425 0 : genre: skippedSong.genre || 'unknown',
426 0 : subgenre: skippedSong.subgenre || null,
427 0 : decade: skippedSong.decade || null,
428 0 : albumCover: skippedSong.albumCover || '',
429 0 : previewURL: skippedSong.previewURL || '',
430 0 : };
431 :
432 0 : const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'}/user-songs/upsert`;
433 0 : const response = await fetch(url, {
434 0 : method: 'POST',
435 0 : headers: { 'Content-Type': 'application/json' },
436 0 : body: JSON.stringify(payload),
437 0 : });
438 0 : if (!response.ok) throw new Error('Failed to skip song');
439 :
440 0 : if (mode === 'rerank') {
441 0 : const reRankSongs = await fetchReRankingData();
442 0 : if (reRankSongs.length > 0) {
443 0 : const newSong = reRankSongs.find(s => String(s.deezerID) !== String(keptSong.deezerID));
444 0 : setCurrentPair(newSong ? [keptSong, newSong] : [keptSong]);
445 0 : } else {
446 0 : setCurrentPair([]);
447 0 : }
448 0 : } else {
449 0 : if (songList.length > 0) {
450 0 : const nextSong = songList[0];
451 0 : setCurrentPair([nextSong, keptSong]);
452 0 : setSongList(songList.slice(1));
453 0 : } else {
454 0 : setCurrentPair([]);
455 0 : }
456 0 : }
457 0 : } catch (error) {
458 0 : console.error('Failed to skip song:', error.message);
459 0 : setCurrentPair([]);
460 0 : } finally {
461 0 : setLoading(false);
462 0 : console.log('Loading set to false');
463 0 : resetProcessing?.();
464 0 : }
465 0 : };
466 :
467 59 : const refreshPair = useCallback(async (resetProcessing) => {
468 0 : if (!contextUserID) {
469 0 : console.error('No userID available for refreshPair');
470 0 : resetProcessing?.();
471 0 : return;
472 0 : }
473 0 : setLoading(true);
474 0 : console.log('Loading set to true');
475 0 : try {
476 0 : if (mode === 'new' && currentPair.length === 2) {
477 0 : await Promise.all([
478 0 : skipSong(currentPair[0].deezerID),
479 0 : skipSong(currentPair[1].deezerID),
480 0 : ]);
481 0 : getNextPair(songList);
482 0 : } else if (mode === 'rerank') {
483 0 : const newPair = await fetchReRankingData();
484 0 : setCurrentPair(newPair.length >= 2 ? newPair : []);
485 0 : }
486 0 : } catch (error) {
487 0 : console.error('Failed to refresh pair:', error);
488 0 : setCurrentPair([]);
489 0 : } finally {
490 0 : setLoading(false);
491 0 : console.log('Loading set to false');
492 0 : resetProcessing?.();
493 0 : }
494 59 : }, [mode, currentPair, songList, skipSong, fetchReRankingData]);
495 :
496 59 : useEffect(() => {
497 12 : setCurrentPair([]);
498 59 : }, [mode]);
499 :
500 59 : return (
501 59 : <SongContext.Provider
502 59 : value={{
503 59 : songList,
504 59 : setSongList,
505 59 : songBuffer,
506 59 : setSongBuffer,
507 59 : currentPair,
508 59 : setCurrentPair,
509 59 : rankedSongs,
510 59 : loading,
511 59 : setLoading,
512 59 : mode,
513 59 : setMode,
514 59 : getNextPair,
515 59 : generateNewSongs,
516 59 : fetchReRankingData,
517 59 : selectSong,
518 59 : skipSong,
519 59 : fetchRankedSongs,
520 59 : refreshPair,
521 59 : selectedGenre,
522 59 : setSelectedGenre,
523 59 : userID: contextUserID,
524 59 : setIsRankPageActive,
525 59 : setLastFilters,
526 59 : setFiltersApplied
527 59 : }}
528 : >
529 59 : {children}
530 59 : </SongContext.Provider>
531 : );
532 59 : };
533 :
534 1 : export default SongProvider;
|