diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index b4d64b4..28d0f11 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -724,6 +724,64 @@ app.get('/api/song-sorttags', async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); +// ─── PLAYLIST MIGRATION HELPERS ────────────────────────────────────────────── +// Used to preserve playlist membership when a file is renamed/moved during edit. + +async function _ndApi(ndUrl, user, pass, method, extra = '') { + const s = Math.random().toString(36).slice(2, 10); + const t = crypto.createHash('md5').update(pass + s).digest('hex'); + const r = await fetch( + `${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json${extra}`, + { signal: AbortSignal.timeout(8000) } + ); + return r.json(); +} + +async function _playlistsContaining(ndUrl, user, pass, songId) { + try { + const d = await _ndApi(ndUrl, user, pass, 'getPlaylists'); + const pls = [].concat(d?.['subsonic-response']?.playlists?.playlist || []); + const affected = []; + for (const pl of pls) { + const det = await _ndApi(ndUrl, user, pass, 'getPlaylist', `&id=${pl.id}`); + const entries = [].concat(det?.['subsonic-response']?.playlist?.entry || []); + if (entries.some(e => e.id === songId)) affected.push(pl.id); + } + return affected; + } catch { return []; } +} + +// Poll Navidrome until the renamed song appears (up to ~5s) +async function _findSongAfterRename(ndUrl, user, pass, title, artist, oldId) { + for (let i = 0; i < 5; i++) { + if (i > 0) await new Promise(r => setTimeout(r, 1200)); + try { + const q = encodeURIComponent(`${artist} ${title}`.trim().slice(0, 100)); + const d = await _ndApi(ndUrl, user, pass, 'search3', `&query=${q}&songCount=20&artistCount=0&albumCount=0`); + const songs = [].concat(d?.['subsonic-response']?.searchResult3?.song || []); + const match = songs.find(s => + s.id !== oldId && + (s.title || '').toLowerCase() === (title || '').toLowerCase() + ); + if (match) return match; + } catch {} + } + return null; +} + +async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) { + for (const plId of plIds) { + try { + const det = await _ndApi(ndUrl, user, pass, 'getPlaylist', `&id=${plId}`); + const entries = [].concat(det?.['subsonic-response']?.playlist?.entry || []); + const idx = entries.findIndex(e => e.id === oldId); + let extra = `&songIdToAdd=${newId}`; + if (idx >= 0) extra += `&songIndexToRemove=${idx}`; + await _ndApi(ndUrl, user, pass, 'updatePlaylist', `&playlistId=${plId}${extra}`); + } catch {} + } +} + // ─── METADATA EDIT ─────────────────────────────────────────────────────────── app.post('/api/edit-metadata', async (req, res) => { const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks, @@ -757,6 +815,10 @@ app.post('/api/edit-metadata', async (req, res) => { return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); } + // Collect playlists containing this song BEFORE the edit so we can restore membership + // if the file gets renamed/moved (which changes the Navidrome song ID). + const affectedPlaylists = await _playlistsContaining(ndUrl, user, pass, songId).catch(() => []); + // If not absolute, try relative to music root if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); @@ -910,6 +972,16 @@ app.post('/api/edit-metadata', async (req, res) => { await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) }); } catch {} + // Restore playlist membership after rename/move — the rescan gives the renamed song a new ID + if (affectedPlaylists.length > 0) { + const effectiveTitle = title || ndSong?.title || ''; + const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim(); + const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId); + if (newSong && newSong.id !== songId) { + await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id); + } + } + res.json({ ok: true }); } catch (e) { try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}