diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index d0133ee..fd3a0af 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -106,8 +106,17 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t // Fetch YTM search result URLs as a flat list (no channel expansion) and return only // actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel // result and treating its uploads as the search result set. + // + // yt-dlp expands "url_transparent" artist channel entries even under --flat-playlist, + // printing the channel's video URLs instead of the channel URL. We detect this by + // watching for [youtube:tab] in output (which only fires during channel/tab extraction) + // and return [] so the caller falls through to the ytsearch fallback. const getYtmVideoUrls = (maxItems) => new Promise(resolve => { const urls = []; + let channelExpanded = false; + const checkExpansion = (text) => { + if (text.includes('[youtube:tab]') || text.includes('/browse/UC')) channelExpanded = true; + }; const proc = spawn(CONFIG.ytdlpBin, [ ytmUrl, '--flat-playlist', @@ -116,15 +125,18 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t '--no-warnings', ], { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); proc.stdout.on('data', d => { - d.toString().split('\n').forEach(line => { - const u = line.trim(); - // Only accept video URLs — skip /browse/ (artist channel) and /playlist? entries - if (u.includes('watch?v=')) urls.push(u); - }); + const text = d.toString(); + checkExpansion(text); + if (!channelExpanded) { + text.split('\n').forEach(line => { + const u = line.trim(); + if (u.includes('watch?v=')) urls.push(u); + }); + } }); - proc.stderr.on('data', () => {}); + proc.stderr.on('data', d => checkExpansion(d.toString())); proc.on('error', () => resolve([])); - proc.on('close', () => resolve(urls)); + proc.on('close', () => resolve(channelExpanded ? [] : urls)); }); // Search YTM for video URLs (pre-filtered), then pass them all at once to yt-dlp. diff --git a/src/main.js b/src/main.js index 211922f..852441a 100644 --- a/src/main.js +++ b/src/main.js @@ -118,8 +118,7 @@ window.addEventListener('load', () => { S.audio.addEventListener('pause', () => { setPlayIcon(false); document.getElementById('player-art').classList.remove('spinning'); - // Skip during track transitions — src change fires pause but we're about to play() immediately - if(!S._changingTrack && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused'; + if(navigator.mediaSession) navigator.mediaSession.playbackState = 'paused'; }); S.previewAudio.addEventListener('timeupdate', updatePreviewProgress); S.previewAudio.addEventListener('ended', stopPreview); diff --git a/src/player.js b/src/player.js index b0826f2..76ee3e2 100644 --- a/src/player.js +++ b/src/player.js @@ -17,14 +17,8 @@ export function playQueue(songs, startIdx) { export function playCurrent() { if(S.qIdx < 0 || S.qIdx >= S.queue.length) return; const s = S.queue[S.qIdx]; - // Pre-update MediaSession before src change so the OS never sees a "paused" flash - // (setting src fires pause synchronously; _changingTrack suppresses that in the pause handler) - updateMediaSession(s); - if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; - S._changingTrack = true; S.audio.src = streamUrl(s.id); - S._changingTrack = false; - S.audio.play().catch(() => {}); + S.audio.play(); updatePlayerUI(s); onNpSongChange(s.id); renderQueue(); diff --git a/src/state.js b/src/state.js index 84957bc..36216b5 100644 --- a/src/state.js +++ b/src/state.js @@ -12,7 +12,6 @@ export const S = { previewAudio: null, previewCurrent: null, _scrubbing: false, - _changingTrack: false, _msPosLast: 0, queueOpen: false,