diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js
index 15aa01b..72e2ed7 100644
--- a/backend/novasonic-server.js
+++ b/backend/novasonic-server.js
@@ -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,
diff --git a/index.html b/index.html
index b7eb098..1e0bf7e 100644
--- a/index.html
+++ b/index.html
@@ -386,7 +386,7 @@
@@ -395,6 +395,7 @@
Spotify — Track, Album oder Playlist
SoundCloud — Track oder Playlist
+
YouTube — Video oder Playlist
Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.
@@ -425,6 +426,17 @@
+
+
+
diff --git a/src/main.js b/src/main.js
index 935835c..2aa0a10 100644
--- a/src/main.js
+++ b/src/main.js
@@ -20,6 +20,7 @@ import {
} from './views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink } from './spotify.js';
+import { resolveYouTubeLink, executeYouTubeDownload } from './youtube.js';
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js';
import {
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey,
@@ -311,7 +312,7 @@ Object.assign(window, {
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink,
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
- executeSpotifyDownload,
+ executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata,
openDlModal, closeDlModal, selectDlType, executeDownload,
diff --git a/src/spotify.js b/src/spotify.js
index 8191730..1b92a21 100644
--- a/src/spotify.js
+++ b/src/spotify.js
@@ -10,6 +10,7 @@ export function onImporterInput(val) {
// Always clear results when URL changes
document.getElementById('spotify-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' : '';
const badge = document.getElementById('importer-badge');
@@ -29,6 +30,13 @@ export function onImporterInput(val) {
btn.style.background = '#ff5500';
btn.style.color = '#fff';
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 {
badge.textContent = 'URL';
badge.style.background = '';
@@ -48,6 +56,8 @@ export async function resolveImporterLink() {
document.getElementById('importer-hint').style.display = 'none';
if (url.includes('soundcloud.com')) {
window.resolveSoundCloudLink();
+ } else if (url.includes('youtube.com') || url.includes('youtu.be')) {
+ window.resolveYouTubeLink();
} else {
resolveSpotifyLink();
}
diff --git a/src/ui.js b/src/ui.js
index 9d2481a..9c44f97 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -243,9 +243,10 @@ function _renderInfoModal(s) {
document.getElementById('info-top-title').textContent = s.title || '—';
document.getElementById('info-top-sub').textContent = (s.artist||'—') + (s.album ? ' · '+s.album : '');
const fields = [
- ['Titel', s.title],
- ['Künstler', s.artist],
- ['Album', s.album],
+ ['Titel', s.title],
+ ['Künstler', s.artist],
+ ['Album-Künstler', s.albumArtist],
+ ['Album', s.album],
['Jahr', s.year],
['Track #', s.track],
['Disc #', s.discNumber],
diff --git a/src/youtube.js b/src/youtube.js
new file mode 100644
index 0000000..f72fbb1
--- /dev/null
+++ b/src/youtube.js
@@ -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 = '';
+
+ 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 = `Zeitüberschreitung — yt-dlp hat nicht rechtzeitig geantwortet.
`;
+ return;
+ }
+ inner.innerHTML = `${esc(e.message)}
`;
+ }
+}
+
+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 = `
+ `;
+ } else if (data.type === 'playlist') {
+ actionsHtml = `
+ `;
+ }
+
+ const artHtml = art
+ ? `
`
+ : '';
+
+ inner.innerHTML = `
+
+
+
+
+ ${typeLabel}
+
+
${esc(data.name)}
+
${esc(data.artist)}${count > 1 ? ` · ${count} Tracks` : ''}
+ ${actionsHtml}
+
+
`;
+
+ 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);
+}