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:
1 parent
fce53e409d
commit
3db71c24a4
1 file changed
+72
@@ -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 {}
|
||||||
|
|||||||
Reference in new issue
Block a user