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:
1 parent
3df1990d41
commit
ae48ed4129
6 files changed
+198
-2
No files matched your search
@@ -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) => {
|
app.post('/api/download', async (req, res) => {
|
||||||
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit,
|
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit,
|
||||||
trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal,
|
trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal,
|
||||||
|
|||||||
+13
-1
@@ -386,7 +386,7 @@
|
|||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
|
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
|
||||||
<div id="importer-badge" style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--surface2);color:var(--text3);white-space:nowrap;flex-shrink:0">URL</div>
|
<div id="importer-badge" style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--surface2);color:var(--text3);white-space:nowrap;flex-shrink:0">URL</div>
|
||||||
<input id="importer-link-input" type="url" placeholder="Spotify oder SoundCloud Link einfügen…" style="font-size:14px" oninput="onImporterInput(this.value)">
|
<input id="importer-link-input" type="url" placeholder="Spotify, SoundCloud oder YouTube Link einfügen…" style="font-size:14px" oninput="onImporterInput(this.value)">
|
||||||
</div>
|
</div>
|
||||||
<button id="importer-btn" class="btn-outline" style="padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0" onclick="resolveImporterLink()">Importieren</button>
|
<button id="importer-btn" class="btn-outline" style="padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0" onclick="resolveImporterLink()">Importieren</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,6 +395,7 @@
|
|||||||
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#1DB954;font-weight:700">Spotify</span> — Track, Album oder Playlist</div>
|
<div style="display:flex;align-items:center;gap:6px"><span style="color:#1DB954;font-weight:700">Spotify</span> — Track, Album oder Playlist</div>
|
||||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#ff5500;font-weight:700">SoundCloud</span> — Track oder Playlist</div>
|
<div style="display:flex;align-items:center;gap:6px"><span style="color:#ff5500;font-weight:700">SoundCloud</span> — Track oder Playlist</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px"><span style="color:#FF0000;font-weight:700">YouTube</span> — Video oder Playlist</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:6px;color:var(--text3);font-size:11px">Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.</div>
|
<div style="margin-top:6px;color:var(--text3);font-size:11px">Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,6 +426,17 @@
|
|||||||
<div id="sc-tracks-list" class="song-list"></div>
|
<div id="sc-tracks-list" class="song-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- YOUTUBE RESULTS -->
|
||||||
|
<div id="yt-preview" style="display:none;margin-bottom:8px">
|
||||||
|
<div id="yt-preview-inner"></div>
|
||||||
|
<div id="yt-tracks-section" style="display:none">
|
||||||
|
<div class="section-header" style="margin-top:8px">
|
||||||
|
<span class="section-title" id="yt-tracks-title">Tracks</span>
|
||||||
|
</div>
|
||||||
|
<div id="yt-tracks-list" class="song-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+2
-1
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './views.js';
|
} from './views.js';
|
||||||
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||||
onImporterInput, resolveImporterLink } from './spotify.js';
|
onImporterInput, resolveImporterLink } from './spotify.js';
|
||||||
|
import { resolveYouTubeLink, executeYouTubeDownload } from './youtube.js';
|
||||||
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js';
|
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js';
|
||||||
import {
|
import {
|
||||||
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey,
|
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey,
|
||||||
@@ -311,7 +312,7 @@ Object.assign(window, {
|
|||||||
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||||
onImporterInput, resolveImporterLink,
|
onImporterInput, resolveImporterLink,
|
||||||
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
||||||
executeSpotifyDownload,
|
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
|
||||||
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
||||||
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata,
|
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata,
|
||||||
openDlModal, closeDlModal, selectDlType, executeDownload,
|
openDlModal, closeDlModal, selectDlType, executeDownload,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function onImporterInput(val) {
|
|||||||
// Always clear results when URL changes
|
// Always clear results when URL changes
|
||||||
document.getElementById('spotify-preview').style.display = 'none';
|
document.getElementById('spotify-preview').style.display = 'none';
|
||||||
document.getElementById('sc-preview').style.display = 'none';
|
document.getElementById('sc-preview').style.display = 'none';
|
||||||
|
document.getElementById('yt-preview').style.display = 'none';
|
||||||
document.getElementById('importer-hint').style.display = val ? 'none' : '';
|
document.getElementById('importer-hint').style.display = val ? 'none' : '';
|
||||||
|
|
||||||
const badge = document.getElementById('importer-badge');
|
const badge = document.getElementById('importer-badge');
|
||||||
@@ -29,6 +30,13 @@ export function onImporterInput(val) {
|
|||||||
btn.style.background = '#ff5500';
|
btn.style.background = '#ff5500';
|
||||||
btn.style.color = '#fff';
|
btn.style.color = '#fff';
|
||||||
btn.style.borderColor = 'transparent';
|
btn.style.borderColor = 'transparent';
|
||||||
|
} else if (val.includes('youtube.com') || val.includes('youtu.be')) {
|
||||||
|
badge.textContent = 'YouTube';
|
||||||
|
badge.style.background = 'rgba(255,0,0,.15)';
|
||||||
|
badge.style.color = '#FF0000';
|
||||||
|
btn.style.background = '#FF0000';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.borderColor = 'transparent';
|
||||||
} else {
|
} else {
|
||||||
badge.textContent = 'URL';
|
badge.textContent = 'URL';
|
||||||
badge.style.background = '';
|
badge.style.background = '';
|
||||||
@@ -48,6 +56,8 @@ export async function resolveImporterLink() {
|
|||||||
document.getElementById('importer-hint').style.display = 'none';
|
document.getElementById('importer-hint').style.display = 'none';
|
||||||
if (url.includes('soundcloud.com')) {
|
if (url.includes('soundcloud.com')) {
|
||||||
window.resolveSoundCloudLink();
|
window.resolveSoundCloudLink();
|
||||||
|
} else if (url.includes('youtube.com') || url.includes('youtu.be')) {
|
||||||
|
window.resolveYouTubeLink();
|
||||||
} else {
|
} else {
|
||||||
resolveSpotifyLink();
|
resolveSpotifyLink();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ function _renderInfoModal(s) {
|
|||||||
const fields = [
|
const fields = [
|
||||||
['Titel', s.title],
|
['Titel', s.title],
|
||||||
['Künstler', s.artist],
|
['Künstler', s.artist],
|
||||||
|
['Album-Künstler', s.albumArtist],
|
||||||
['Album', s.album],
|
['Album', s.album],
|
||||||
['Jahr', s.year],
|
['Jahr', s.year],
|
||||||
['Track #', s.track],
|
['Track #', s.track],
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { S } from './state.js';
|
||||||
|
import { esc, toast } from './utils.js';
|
||||||
|
import { makeGlobalTrackItem } from './components.js';
|
||||||
|
|
||||||
|
export async function resolveYouTubeLink() {
|
||||||
|
const url = document.getElementById('importer-link-input')?.value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
if (!S.cfg.backendUrl) { toast('⚠ Backend URL in Einstellungen konfigurieren'); return; }
|
||||||
|
|
||||||
|
const preview = document.getElementById('yt-preview');
|
||||||
|
const inner = document.getElementById('yt-preview-inner');
|
||||||
|
const section = document.getElementById('yt-tracks-section');
|
||||||
|
|
||||||
|
preview.style.display = '';
|
||||||
|
section.style.display = 'none';
|
||||||
|
inner.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${S.cfg.backendUrl}/api/youtube/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
signal: AbortSignal.timeout(50000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
window._ytResolved = data;
|
||||||
|
_renderYouTubePreview(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'TimeoutError' || e.name === 'AbortError') {
|
||||||
|
inner.innerHTML = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">Zeitüberschreitung — yt-dlp hat nicht rechtzeitig geantwortet.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inner.innerHTML = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderYouTubePreview(data) {
|
||||||
|
const inner = document.getElementById('yt-preview-inner');
|
||||||
|
const section = document.getElementById('yt-tracks-section');
|
||||||
|
const list = document.getElementById('yt-tracks-list');
|
||||||
|
const titleEl = document.getElementById('yt-tracks-title');
|
||||||
|
|
||||||
|
const typeLabel = data.type === 'playlist' ? 'YouTube Playlist' : 'YouTube';
|
||||||
|
const YT_RED = '#FF0000';
|
||||||
|
const art = (data.artworkUrl || '').replace(/\/hqdefault\.jpg/, '/maxresdefault.jpg');
|
||||||
|
const count = data.tracks?.length || 0;
|
||||||
|
|
||||||
|
let actionsHtml = '';
|
||||||
|
if (data.type === 'track' && count > 0) {
|
||||||
|
actionsHtml = `
|
||||||
|
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600;margin-top:2px"
|
||||||
|
onclick="executeYouTubeDownload({type:'track',title:window._ytResolved.tracks[0].trackName,artist:window._ytResolved.tracks[0].artistName,artworkUrl:window._ytResolved.artworkUrl,tracks:window._ytResolved.tracks})">
|
||||||
|
⬇ Track laden
|
||||||
|
</button>`;
|
||||||
|
} else if (data.type === 'playlist') {
|
||||||
|
actionsHtml = `
|
||||||
|
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600;margin-top:2px"
|
||||||
|
onclick="executeYouTubeDownload({type:'playlist',title:window._ytResolved.name,artist:window._ytResolved.artist,artworkUrl:window._ytResolved.artworkUrl,tracks:window._ytResolved.tracks})">
|
||||||
|
⬇ Alle ${count} Tracks laden
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artHtml = art
|
||||||
|
? `<img src="${art}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
inner.innerHTML = `
|
||||||
|
<div style="display:flex;gap:16px;align-items:flex-start;background:var(--surface);border-radius:14px;padding:18px 16px;margin-bottom:4px;border:1px solid var(--border);border-top:3px solid ${YT_RED}">
|
||||||
|
<div style="width:110px;height:110px;border-radius:10px;overflow:hidden;flex-shrink:0;background:var(--surface2)">
|
||||||
|
${artHtml}
|
||||||
|
<div style="width:100%;height:100%;display:${art?'none':'flex'};align-items:center;justify-content:center;font-size:32px">▶</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:10px;font-weight:700;color:${YT_RED};text-transform:uppercase;letter-spacing:1.5px;margin-bottom:5px">
|
||||||
|
${typeLabel}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:18px;font-weight:700;margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(data.name)}</div>
|
||||||
|
<div style="font-size:13px;color:var(--text2);margin-bottom:10px">${esc(data.artist)}${count > 1 ? `<span style="color:var(--text3)"> · ${count} Tracks</span>` : ''}</div>
|
||||||
|
${actionsHtml}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
titleEl.textContent = `${count} Tracks`;
|
||||||
|
list.innerHTML = '';
|
||||||
|
(data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
|
||||||
|
section.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeYouTubeDownload(opts) {
|
||||||
|
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
|
||||||
|
window.executeSpotifyDownload(opts);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user