fix: restore playlist membership after metadata edit renames/moves file

When a file is renamed or moved to a new artist/album folder during
metadata edit, Navidrome assigns a new song ID, breaking all playlist
entries that referenced the old ID.

Before the edit: collect which playlists contain the song.
After rename + rescan: poll Navidrome until the new song appears,
then update each affected playlist (add new ID, remove old index).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-28 00:01:50 +02:00
1 parent fce53e409d
commit 3db71c24a4
1 file changed
+72
+72
View File
@@ -724,6 +724,64 @@ app.get('/api/song-sorttags', async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } 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 ─────────────────────────────────────────────────────────── // ─── METADATA EDIT ───────────────────────────────────────────────────────────
app.post('/api/edit-metadata', async (req, res) => { app.post('/api/edit-metadata', async (req, res) => {
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks, 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}` }); 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 not absolute, try relative to music root
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); 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) }); 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 {} } 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 }); res.json({ ok: true });
} catch (e) { } catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {} try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}