Skip to content

Commit afd0912

Browse files
committed
feat: Implement robust audio playback error handling with retry logic and improve search relevance scoring.
1 parent 0f961f9 commit afd0912

3 files changed

Lines changed: 206 additions & 52 deletions

File tree

client/src/Context/PlayerContext.jsx

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ export function PlayerProvider({ children }) {
99
const nextAudioRef = useRef(null)
1010
const prevSongIdRef = useRef(null)
1111
const lastUpdateTime = useRef(0)
12+
const retryCountRef = useRef(0)
13+
const retryTimerRef = useRef(null)
14+
const MAX_RETRIES = 3
1215

1316
const setAudioRefs = usePlayerStore((s) => s.setAudioRefs)
1417
const loadAndPlayCurrentSong = usePlayerStore((s) => s.loadAndPlayCurrentSong)
1518
const handleNextSong = usePlayerStore((s) => s.handleNextSong)
1619
const updateTime = usePlayerStore((s) => s.updateTime)
1720
const setDuration = usePlayerStore((s) => s.setDuration)
21+
const setPlaying = usePlayerStore((s) => s.setPlaying)
22+
const setLoading = usePlayerStore((s) => s.setLoading)
1823
const setUserPlaylist = usePlayerStore((s) => s.setUserPlaylist)
1924
const decrementSongsRemaining = usePlayerStore((s) => s.decrementSongsRemaining)
2025

@@ -37,13 +42,21 @@ export function PlayerProvider({ children }) {
3742
}
3843
}, [setAudioRefs])
3944

45+
useEffect(() => {
46+
retryCountRef.current = 0
47+
if (retryTimerRef.current) {
48+
clearTimeout(retryTimerRef.current)
49+
retryTimerRef.current = null
50+
}
51+
}, [currentSong?.id])
52+
4053
useEffect(() => {
4154
const audio = audioRef.current
4255
if (!audio) return
4356

4457
const handleTimeUpdate = () => {
4558
const now = Date.now()
46-
if (now - lastUpdateTime.current > 1000) {
59+
if (now - lastUpdateTime.current > 500) {
4760
updateTime(audio.currentTime)
4861
lastUpdateTime.current = now
4962
}
@@ -58,16 +71,82 @@ export function PlayerProvider({ children }) {
5871
handleNextSong(true)
5972
}
6073

74+
const handleError = () => {
75+
if (!audio.src || audio.src === window.location.href) return
76+
77+
if (retryCountRef.current < MAX_RETRIES) {
78+
retryCountRef.current += 1
79+
const delay = Math.pow(2, retryCountRef.current) * 500
80+
console.warn(`Audio error, retrying (${retryCountRef.current}/${MAX_RETRIES}) in ${delay}ms`)
81+
setLoading(true)
82+
83+
const savedTime = audio.currentTime || 0
84+
const savedSrc = audio.src
85+
86+
retryTimerRef.current = setTimeout(() => {
87+
retryTimerRef.current = null
88+
audio.src = savedSrc
89+
audio.load()
90+
91+
const onCanPlayRetry = () => {
92+
audio.removeEventListener("canplay", onCanPlayRetry)
93+
if (savedTime > 0) {
94+
audio.currentTime = savedTime
95+
}
96+
audio.play().catch(() => {
97+
setPlaying(false)
98+
setLoading(false)
99+
})
100+
}
101+
audio.addEventListener("canplay", onCanPlayRetry)
102+
}, delay)
103+
} else {
104+
console.error(`Audio failed after ${MAX_RETRIES} retries`)
105+
setPlaying(false)
106+
setLoading(false)
107+
}
108+
}
109+
110+
const handlePause = () => {
111+
if (!audio.ended) {
112+
setPlaying(false)
113+
}
114+
}
115+
116+
const handlePlaying = () => {
117+
retryCountRef.current = 0
118+
setPlaying(true)
119+
setLoading(false)
120+
}
121+
122+
const handleWaiting = () => {
123+
setLoading(true)
124+
}
125+
126+
const handleCanPlay = () => {
127+
setLoading(false)
128+
}
129+
61130
audio.addEventListener("timeupdate", handleTimeUpdate)
62131
audio.addEventListener("loadedmetadata", handleLoadedMetadata)
63132
audio.addEventListener("ended", handleEnded)
133+
audio.addEventListener("error", handleError)
134+
audio.addEventListener("pause", handlePause)
135+
audio.addEventListener("playing", handlePlaying)
136+
audio.addEventListener("waiting", handleWaiting)
137+
audio.addEventListener("canplay", handleCanPlay)
64138

65139
return () => {
66140
audio.removeEventListener("timeupdate", handleTimeUpdate)
67141
audio.removeEventListener("loadedmetadata", handleLoadedMetadata)
68142
audio.removeEventListener("ended", handleEnded)
143+
audio.removeEventListener("error", handleError)
144+
audio.removeEventListener("pause", handlePause)
145+
audio.removeEventListener("playing", handlePlaying)
146+
audio.removeEventListener("waiting", handleWaiting)
147+
audio.removeEventListener("canplay", handleCanPlay)
69148
}
70-
}, [handleNextSong, volume, updateTime, setDuration])
149+
}, [handleNextSong, volume, updateTime, setDuration, setPlaying, setLoading])
71150

72151
useEffect(() => {
73152
const loadSong = async () => {
@@ -82,6 +161,17 @@ export function PlayerProvider({ children }) {
82161
}
83162
}, [currentSong?.id, decrementSongsRemaining])
84163

164+
useEffect(() => {
165+
const saveTimeOnUnload = () => {
166+
const time = audioRef.current?.currentTime
167+
if (time > 0) {
168+
localStorage.setItem("player-time", String(time))
169+
}
170+
}
171+
window.addEventListener("beforeunload", saveTimeOnUnload)
172+
return () => window.removeEventListener("beforeunload", saveTimeOnUnload)
173+
}, [])
174+
85175
return (
86176
<>
87177
{children}

client/src/stores/playerStore.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import axios from "axios"
66

77
let audioElement = null
88
let nextAudioElement = null
9+
const TIME_STORAGE_KEY = "player-time"
910

1011
const getAudioUrl = (song) => {
1112
return song?.download_url?.[4]?.link || song?.download_url?.[3]?.link || ""
@@ -48,7 +49,7 @@ export const usePlayerStore = create(
4849
if (!isInQueue) {
4950
set({ playlist: [secureAudio] })
5051
}
51-
set({ currentSong: secureAudio, isLoading: false })
52+
set({ currentSong: secureAudio, isLoading: false, currentTime: 0, duration: 0 })
5253
},
5354

5455
stopSong: () => {
@@ -66,10 +67,16 @@ export const usePlayerStore = create(
6667
if (!audioElement) return
6768
if (isPlaying) {
6869
audioElement.pause()
70+
set({ isPlaying: false })
6971
} else {
70-
audioElement.play().catch(console.error)
72+
audioElement
73+
.play()
74+
.then(() => set({ isPlaying: true }))
75+
.catch((err) => {
76+
console.error("Play failed:", err)
77+
set({ isPlaying: false })
78+
})
7179
}
72-
set({ isPlaying: !isPlaying })
7380
},
7481

7582
setPlaying: (isPlaying) => set({ isPlaying }),
@@ -332,24 +339,28 @@ export const usePlayerStore = create(
332339
return prevSongId
333340
}
334341
try {
342+
set({ isLoading: true })
335343
audioElement.src = getAudioUrl(currentSong)
336-
await audioElement.load()
337344

338345
if (!_hasRestoredTime && currentTime > 0) {
339346
audioElement.currentTime = currentTime
340-
set({ _hasRestoredTime: true })
347+
set({ _hasRestoredTime: true, isLoading: false })
341348
} else {
349+
set({ currentTime: 0 })
342350
await audioElement.play()
343-
set({ isPlaying: true, _hasRestoredTime: true })
351+
set({ isPlaying: true, _hasRestoredTime: true, isLoading: false })
344352
addToHistory(currentSong, 0, "autoplay")
345353
}
346354

347355
updateMediaSession(currentSong)
348356
preloadNextTrack()
349357
return currentSong.id
350358
} catch (err) {
359+
if (err.name === "AbortError") {
360+
return prevSongId
361+
}
351362
console.error("Playback error:", err)
352-
set({ isPlaying: false })
363+
set({ isPlaying: false, isLoading: false })
353364
return prevSongId
354365
}
355366
},
@@ -361,14 +372,19 @@ export const usePlayerStore = create(
361372
playlist: state.playlist,
362373
originalPlaylist: state.originalPlaylist,
363374
volume: state.volume,
364-
currentTime: state.currentTime,
365375
shuffleMode: state.shuffleMode,
366376
repeatMode: state.repeatMode,
367377
}),
368378
onRehydrateStorage: () => (state) => {
369379
if (state) {
370380
state.isPlaying = false
371381
state.isLoading = false
382+
try {
383+
const savedTime = parseFloat(localStorage.getItem(TIME_STORAGE_KEY))
384+
if (savedTime > 0) {
385+
state.currentTime = savedTime
386+
}
387+
} catch {}
372388
}
373389
},
374390
},

server/controllers/music/searchController.js

Lines changed: 90 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ const { Op } = require("sequelize")
33
const sequelize = require("../../utils/sequelize")
44

55
const SONG_API_URL = process.env.SONG_API_URL || "https://song.thakur.dev"
6-
const MIN_DB_RESULTS = 5
76

87
async function fetchExternalSearch(query, limit = 30) {
98
try {
@@ -18,6 +17,35 @@ async function fetchExternalSearch(query, limit = 30) {
1817
}
1918
}
2019

20+
function computeRelevanceScore(song, query) {
21+
const q = query.toLowerCase()
22+
const name = (song.name || "").toLowerCase()
23+
const artist = (song.artist_map?.artists?.map((a) => a.name).join(" ") || "").toLowerCase()
24+
const album = (song.album || "").toLowerCase()
25+
26+
const exactName = name === q ? 1.0 : 0
27+
const startsWithName = name.startsWith(q) ? 0.5 : 0
28+
const includesName = name.includes(q) ? 0.3 : 0
29+
const includesArtist = artist.includes(q) ? 0.25 : 0
30+
const includesAlbum = album.includes(q) ? 0.15 : 0
31+
32+
const words = q.split(/\s+/)
33+
const wordMatchRatio =
34+
words.length > 1
35+
? words.filter((w) => name.includes(w) || artist.includes(w)).length / words.length
36+
: 0
37+
38+
return Math.min(
39+
exactName +
40+
startsWithName +
41+
includesName +
42+
includesArtist +
43+
includesAlbum +
44+
wordMatchRatio * 0.4,
45+
1.0,
46+
)
47+
}
48+
2149
const searchSongs = async (req, res) => {
2250
try {
2351
const { q: query, limit = 20, page = 1 } = req.query
@@ -30,58 +58,78 @@ const searchSongs = async (req, res) => {
3058
const offset = (parseInt(page, 10) - 1) * searchLimit
3159
const q = query.toLowerCase().trim()
3260

33-
const dbResults = await Song.findAll({
34-
where: {
35-
[Op.or]: [
36-
sequelize.literal(`similarity("Song"."name", ${sequelize.escape(q)}) > 0.2`),
37-
sequelize.literal(`similarity("Song"."artistNames", ${sequelize.escape(q)}) > 0.2`),
38-
sequelize.literal(`similarity("Song"."albumName", ${sequelize.escape(q)}) > 0.2`),
39-
],
40-
},
41-
order: [
42-
[
43-
sequelize.literal(`
44-
GREATEST(
45-
similarity("Song"."name", ${sequelize.escape(q)}),
46-
similarity("Song"."artistNames", ${sequelize.escape(q)}),
47-
similarity("Song"."albumName", ${sequelize.escape(q)})
48-
)
49-
`),
50-
"DESC",
61+
const [dbResults, externalResults] = await Promise.all([
62+
Song.findAll({
63+
where: {
64+
[Op.or]: [
65+
sequelize.literal(`similarity("Song"."name", ${sequelize.escape(q)}) > 0.15`),
66+
sequelize.literal(`similarity("Song"."artistNames", ${sequelize.escape(q)}) > 0.15`),
67+
sequelize.literal(`similarity("Song"."albumName", ${sequelize.escape(q)}) > 0.15`),
68+
],
69+
},
70+
order: [
71+
[
72+
sequelize.literal(`
73+
GREATEST(
74+
similarity("Song"."name", ${sequelize.escape(q)}),
75+
similarity("Song"."artistNames", ${sequelize.escape(q)}),
76+
similarity("Song"."albumName", ${sequelize.escape(q)})
77+
)
78+
`),
79+
"DESC",
80+
],
5181
],
52-
],
53-
limit: searchLimit,
54-
offset,
55-
attributes: ["songData", "songId"],
56-
})
57-
58-
const dbSongs = dbResults.map((r) => r.songData)
59-
const dbSongIds = new Set(dbResults.map((r) => r.songId))
82+
limit: searchLimit,
83+
offset,
84+
attributes: {
85+
include: [
86+
[
87+
sequelize.literal(`
88+
GREATEST(
89+
similarity("Song"."name", ${sequelize.escape(q)}),
90+
similarity("Song"."artistNames", ${sequelize.escape(q)}),
91+
similarity("Song"."albumName", ${sequelize.escape(q)})
92+
)
93+
`),
94+
"similarityScore",
95+
],
96+
],
97+
},
98+
}),
99+
fetchExternalSearch(query, searchLimit),
100+
])
60101

61-
let externalSongs = []
62-
const needMoreResults = dbSongs.length < MIN_DB_RESULTS
102+
const scoredMap = new Map()
63103

64-
if (needMoreResults) {
65-
const externalResults = await fetchExternalSearch(query, searchLimit)
66-
externalSongs = externalResults.filter((song) => !dbSongIds.has(song.id))
104+
for (const row of dbResults) {
105+
const score = parseFloat(row.getDataValue("similarityScore")) || 0
106+
scoredMap.set(row.songId, { song: row.songData, score })
107+
}
67108

68-
if (externalSongs.length > 0) {
69-
Song.bulkGetOrCreate(externalSongs).catch((err) => {
70-
console.error("[SearchController] Background save failed:", err.message)
71-
})
109+
for (const song of externalResults) {
110+
const score = computeRelevanceScore(song, q)
111+
const existing = scoredMap.get(song.id)
112+
if (!existing || score > existing.score) {
113+
scoredMap.set(song.id, { song, score })
72114
}
73115
}
74116

75-
const combinedSongs = [...dbSongs, ...externalSongs].slice(0, searchLimit)
117+
const ranked = [...scoredMap.values()].sort((a, b) => b.score - a.score).slice(0, searchLimit)
118+
119+
const newExternalSongs = externalResults.filter(
120+
(s) => !dbResults.some((r) => r.songId === s.id),
121+
)
122+
if (newExternalSongs.length > 0) {
123+
Song.bulkGetOrCreate(newExternalSongs).catch((err) => {
124+
console.error("[SearchController] Background save failed:", err.message)
125+
})
126+
}
76127

77128
res.status(200).json({
78129
status: "success",
79130
data: {
80-
songs: combinedSongs,
81-
count: combinedSongs.length,
82-
source: needMoreResults ? "hybrid" : "database",
83-
dbCount: dbSongs.length,
84-
externalCount: externalSongs.length,
131+
songs: ranked.map((r) => r.song),
132+
count: ranked.length,
85133
},
86134
})
87135
} catch (error) {

0 commit comments

Comments
 (0)