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 = ` +
+
+ ${artHtml} +
▶
+
+
+
+ ${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); +}