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
+201
-5
No files matched your search
@@ -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