feat: YouTube/YT Music importer + album artist in view mode

- Info modal view now shows Album-Künstler row from Navidrome albumArtist
- Song Importer detects youtube.com / youtu.be URLs (red badge)
- New /api/youtube/resolve endpoint: yt-dlp --dump-json for single
  videos and playlists (up to 50 entries), enriched with iTunes lookup
  for proper album/year/genre/cover on single tracks
- New src/youtube.js renders preview card and delegates download to
  executeSpotifyDownload (which already handles sourceUrl → direct yt-dlp)
- Importer hint updated to list YouTube as a supported source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-23 21:55:37 +02:00
1 parent 3df1990d41
commit ae48ed4129
6 files changed
+201 -5

No files matched your search

+74
View File
@@ -240,6 +240,80 @@ app.post('/api/spotify/resolve', async (req, res) => {
}
});
// Strip " - Topic" suffix YouTube Music appends to artist channel names
function _cleanYtArtist(name) {
return (name || '').replace(/\s*-\s*Topic$/i, '').trim();
}
app.post('/api/youtube/resolve', async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'url required' });
try {
const { execFile } = require('child_process');
const stdout = await new Promise((resolve, reject) => {
execFile(CONFIG.ytdlpBin, [
'--dump-json', '--flat-playlist', '--no-warnings',
'--playlist-items', '1-50',
url,
], { timeout: 45000, maxBuffer: 20 * 1024 * 1024 }, (err, out, stderr) => {
if (err && !out?.trim()) return reject(new Error(stderr?.trim() || err.message));
resolve(out || '');
});
});
const entries = stdout.trim().split('\n')
.map(l => { try { return JSON.parse(l.trim()); } catch { return null; } })
.filter(Boolean);
if (!entries.length) return res.status(404).json({ error: 'Keine Ergebnisse von yt-dlp' });
const isPlaylist = entries.length > 1 || entries[0]?._type === 'playlist';
if (isPlaylist) {
const plTitle = entries[0]?.playlist_title || entries[0]?.playlist || 'YouTube Playlist';
const plArtist = _cleanYtArtist(entries[0]?.uploader || entries[0]?.channel || '');
const tracks = entries.map(e => ({
trackName: e.title || '',
artistName: _cleanYtArtist(e.uploader || e.channel || plArtist),
artworkUrl: e.thumbnail || '',
durationMs: e.duration ? Math.round(e.duration * 1000) : undefined,
sourceUrl: e.webpage_url || e.url || (e.id ? `https://www.youtube.com/watch?v=${e.id}` : ''),
}));
return res.json({ type: 'playlist', name: plTitle, artist: plArtist,
artworkUrl: entries[0]?.thumbnail || '', tracks });
}
// Single video — enrich with iTunes
const e = entries[0];
const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || '');
const title = e.title || '';
const sourceUrl = e.webpage_url || url;
let itunes = {};
try { itunes = await lookupItunesTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
const artist = itunes.artist || rawArtist;
const artworkUrl = itunes.artworkUrl || e.thumbnail || '';
return res.json({
type: 'track', name: title, artist, artworkUrl,
tracks: [{
trackName: title,
artistName: artist,
artworkUrl: artworkUrl,
collectionName: itunes.album || '',
releaseDate: itunes.year ? String(itunes.year) : '',
trackNumber: itunes.trackNum || undefined,
durationMs: itunes.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: itunes.genre || '',
sourceUrl,
}],
});
} catch (e) {
console.error('[youtube/resolve]', e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/api/download', async (req, res) => {
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit,
trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal,