diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js
index 5322dff..8af4c6e 100644
--- a/backend/lib/ytdlp.js
+++ b/backend/lib/ytdlp.js
@@ -47,7 +47,7 @@ function buildVariantFilter(title) {
return parts.length ? parts.join(' & ') : '';
}
-async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) {
+async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) {
const mainArtist = albumArtist || artist;
const safeArtist = sanitize(mainArtist.split(',')[0].trim());
const safeAlbum = sanitize(album || 'Singles');
@@ -109,6 +109,15 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`);
const hasAudio = () => fs.existsSync(expectedAudio);
+ // Direct URL download (e.g. SoundCloud) β skip the ytsearch cascade entirely
+ if (sourceUrl) {
+ log(jobId, `π Direkter Download: ${sourceUrl}`);
+ await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
+ const audioFile = hasAudio() ? expectedAudio : null;
+ if (!audioFile) { log(jobId, 'β οΈ Download fehlgeschlagen'); return { success: true, dir }; }
+ return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
+ }
+
// Fetch YTM search result URLs as a flat list (no channel expansion) and return only
// actual video URLs (watch?v=β¦). This prevents yt-dlp from following an artist channel
// result and treating its uploads as the search result set.
diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js
index d7105d1..13858a9 100644
--- a/backend/novasonic-server.js
+++ b/backend/novasonic-server.js
@@ -226,7 +226,8 @@ app.post('/api/spotify/resolve', async (req, res) => {
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 } = req.body;
+ trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal,
+ sourceUrl } = req.body;
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
const jobId = clientJobId || ('job_' + Date.now());
@@ -263,7 +264,7 @@ app.post('/api/download', async (req, res) => {
} else {
log(jobId, `π Album aus Spotify: "${meta.album}"`);
}
- const result = await downloadTrack({ jobId, artist, title, ...meta });
+ const result = await downloadTrack({ jobId, artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden β ΓΌbersprungen', skipped: true });
} else {
@@ -350,6 +351,7 @@ app.post('/api/download', async (req, res) => {
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.durationMs || track.durationMs || undefined,
+ sourceUrl: track.sourceUrl || undefined,
});
}));
for (let j = 0; j < batchResults.length; j++) {
@@ -389,6 +391,68 @@ app.get('/api/jobs', (req, res) => {
res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50));
});
+// βββ SOUNDCLOUD RESOLVE ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+app.post('/api/soundcloud/resolve', async (req, res) => {
+ const { url } = req.body;
+ if (!url || !url.includes('soundcloud.com')) return res.status(400).json({ error: 'SoundCloud URL required' });
+ try {
+ const { execFile } = require('child_process');
+ const data = await new Promise((resolve, reject) => {
+ execFile(
+ CONFIG.ytdlpBin,
+ ['--flat-playlist', '--dump-single-json', '--no-warnings', url],
+ { timeout: 30000, maxBuffer: 5 * 1024 * 1024 },
+ (err, stdout, stderr) => {
+ if (err) return reject(new Error(`yt-dlp: ${stderr?.trim() || err.message}`));
+ try { resolve(JSON.parse(stdout.trim())); }
+ catch { reject(new Error('yt-dlp: ungΓΌltige Ausgabe')); }
+ }
+ );
+ });
+
+ const isPlaylist = data._type === 'playlist' || Array.isArray(data.entries);
+ if (isPlaylist) {
+ const entries = data.entries || [];
+ const tracks = entries.map(e => ({
+ trackName: e.title || '',
+ artistName: e.uploader || e.artist || data.uploader || '',
+ artworkUrl: e.thumbnail || '',
+ duration: e.duration || 0,
+ genre: e.genre || '',
+ sourceUrl: e.url || e.webpage_url || '',
+ }));
+ res.json({
+ type: 'playlist',
+ name: data.title || data.uploader || 'SoundCloud Playlist',
+ artist: data.uploader || '',
+ artworkUrl: data.thumbnail || tracks[0]?.artworkUrl || '',
+ tracks,
+ });
+ } else {
+ const track = {
+ trackName: data.title || '',
+ artistName: data.uploader || data.artist || '',
+ artworkUrl: data.thumbnail || '',
+ duration: data.duration || 0,
+ genre: data.genre || '',
+ album: data.album || '',
+ year: data.upload_date ? parseInt(data.upload_date.slice(0, 4), 10) : undefined,
+ sourceUrl: url,
+ };
+ res.json({
+ type: 'track',
+ name: track.trackName,
+ artist: track.artistName,
+ artworkUrl: track.artworkUrl,
+ tracks: [track],
+ });
+ }
+ } catch (e) {
+ console.error('[soundcloud/resolve]', e.message);
+ res.status(500).json({ error: e.message });
+ }
+});
+
// βββ MUSICBRAINZ LOOKUP ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
app.get('/api/musicbrainz/lookup', async (req, res) => {
const { artist, title } = req.query;
diff --git a/index.html b/index.html
index 449736f..95fdea1 100644
--- a/index.html
+++ b/index.html
@@ -84,7 +84,7 @@
ZufΓ€llig
-
Spotify Importer
+
Song Importer
@@ -378,26 +378,27 @@
-
+
+
+
+
-
Importieren
+
Importieren
-
-
So funktioniert's
-
1. Spotify Link fΓΌr Track, Album oder Playlist einfΓΌgen
-
2. Metadaten werden direkt von Spotify geladen
-
3. Songs via yt-dlp herunterladen β direkt in Navidrome Library
-
Kein Spotify-Account erforderlich β funktioniert direkt ΓΌber das Backend.
+
+ Track, Album oder Playlist-Link einfΓΌgen β Metadaten werden direkt von Spotify geladen und Songs via yt-dlp in die Library heruntergeladen.
-
+
+
+
+
+
+
+ Track oder Playlist-Link einfΓΌgen β Metadaten (Titel, Artist, Genre, Artwork) werden direkt von SoundCloud geladen und der Audio-Stream heruntergeladen.
+
+
diff --git a/src/main.js b/src/main.js
index 04a8430..e77266a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -19,6 +19,7 @@ import {
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js';
+import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll } from './soundcloud.js';
import {
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey,
absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter,
@@ -307,6 +308,7 @@ Object.assign(window, {
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
+ loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
executeSpotifyDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata,
diff --git a/src/soundcloud.js b/src/soundcloud.js
new file mode 100644
index 0000000..0649951
--- /dev/null
+++ b/src/soundcloud.js
@@ -0,0 +1,162 @@
+import { S } from './state.js';
+import { esc, toast } from './utils.js';
+// Circular imports β safe (only used inside function bodies)
+import { executeSpotifyDownload } from './ui.js';
+
+export function loadSoundCloudImporter() {
+ const input = document.getElementById('sc-link-input');
+ if (!input?.value) _showHint();
+}
+
+export function clearSoundCloudPreview() {
+ if (!document.getElementById('sc-link-input')?.value) _showHint();
+}
+
+function _showHint() {
+ document.getElementById('sc-importer-hint').style.display = '';
+ document.getElementById('sc-preview').style.display = 'none';
+}
+
+export async function resolveSoundCloudLink() {
+ const url = document.getElementById('sc-link-input')?.value.trim();
+ if (!url) return;
+ if (!url.includes('soundcloud.com')) { toast('β Kein SoundCloud-Link'); return; }
+ if (!S.cfg.backendUrl) { toast('β Backend URL in Einstellungen konfigurieren'); return; }
+
+ const hint = document.getElementById('sc-importer-hint');
+ const preview = document.getElementById('sc-preview');
+ const inner = document.getElementById('sc-preview-inner');
+ const section = document.getElementById('sc-tracks-section');
+
+ hint.style.display = 'none';
+ preview.style.display = '';
+ section.style.display = 'none';
+ inner.innerHTML = '
';
+
+ try {
+ const res = await fetch(`${S.cfg.backendUrl}/api/soundcloud/resolve`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url }),
+ signal: AbortSignal.timeout(35000),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ const data = await res.json();
+ window._scResolved = data;
+ _renderSoundCloudPreview(data);
+ } catch(e) {
+ if (e.name === 'TimeoutError' || e.name === 'AbortError') {
+ inner.innerHTML = `
ZeitΓΌberschreitung β Backend antwortet nicht. LΓ€uft yt-dlp?
`;
+ return;
+ }
+ inner.innerHTML = `
${esc(e.message)}
`;
+ }
+}
+
+export function scDlAll() {
+ const data = window._scResolved;
+ if (!data) return;
+ executeSpotifyDownload({
+ type: 'playlist',
+ title: data.name,
+ artist: data.artist,
+ artworkUrl: data.artworkUrl,
+ tracks: data.tracks,
+ });
+}
+
+export function scDlSingle() {
+ const data = window._scResolved;
+ if (!data?.tracks?.[0]) return;
+ const t = data.tracks[0];
+ executeSpotifyDownload({
+ type: 'track',
+ title: t.trackName,
+ artist: t.artistName,
+ artworkUrl: t.artworkUrl,
+ tracks: [t],
+ });
+}
+
+export function scDlTrack(idx) {
+ const data = window._scResolved;
+ const t = data?.tracks?.[idx];
+ if (!t) return;
+ executeSpotifyDownload({
+ type: 'track',
+ title: t.trackName,
+ artist: t.artistName,
+ artworkUrl: t.artworkUrl,
+ tracks: [t],
+ });
+}
+
+function _renderSoundCloudPreview(data) {
+ const inner = document.getElementById('sc-preview-inner');
+ const section = document.getElementById('sc-tracks-section');
+ const list = document.getElementById('sc-tracks-list');
+ const titleEl = document.getElementById('sc-tracks-title');
+
+ const count = data.tracks?.length || 0;
+ const art = data.artworkUrl || '';
+
+ let actionsHtml = '';
+ if (data.type === 'track' && count > 0) {
+ const t = data.tracks[0];
+ actionsHtml = `
+
+ β¬ Track laden
+ `;
+ } else {
+ actionsHtml = `
+
+ β¬ Alle ${count} Tracks laden
+ `;
+ }
+
+ const artHtml = art
+ ? `
`
+ : '';
+
+ inner.innerHTML = `
+
+
+
+
+ SoundCloud ${data.type === 'playlist' ? 'Playlist' : 'Track'}
+
+
${esc(data.name)}
+
${esc(data.artist)}${count > 1 ? ` Β· ${count} Tracks ` : ''}
+ ${actionsHtml}
+
+
`;
+
+ titleEl.textContent = `${count} Track${count !== 1 ? 's' : ''}`;
+ list.innerHTML = '';
+ (data.tracks || []).forEach((t, i) => {
+ const el = document.createElement('div');
+ el.className = 'gtrack-item';
+ const artT = t.artworkUrl || '';
+ el.innerHTML = `
+
${artT ? `
` : '
π΅
'}
+
+
${esc(t.trackName || 'β')}
+
${esc(t.artistName || 'β')}${t.genre ? ' Β· ' + esc(t.genre) : ''}${t.duration ? ' Β· ' + Math.round(t.duration / 60) + ' min' : ''}
+
+
`;
+ list.appendChild(el);
+ });
+ section.style.display = '';
+}
diff --git a/src/views.js b/src/views.js
index 4ada3ac..d4f417a 100644
--- a/src/views.js
+++ b/src/views.js
@@ -8,6 +8,7 @@ import { closeNowPlaying, closeNpMenu } from './nowplaying.js';
import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js';
import { fetchRomajiIndex } from './api.js';
import { loadSpotifyImporter } from './spotify.js';
+import { loadSoundCloudImporter } from './soundcloud.js';
// βββ NAVIGATION HISTORY βββ
export function _navSave() {
@@ -52,7 +53,7 @@ export function navigate(page, data=null) {
if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
if(page==='recommendations') loadRecommendations();
- if(page==='spotify-importer') loadSpotifyImporter();
+ if(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
if(page==='audiobooks') {
import('./abs.js').then(m => {
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
diff --git a/styles.css b/styles.css
index 56734a4..ea573cd 100644
--- a/styles.css
+++ b/styles.css
@@ -775,6 +775,7 @@ button:focus{outline:none}
.list-search{width:100%;box-sizing:border-box;padding:8px 12px 8px 32px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;color:var(--text1);font-size:13px;font-family:var(--font);outline:none;transition:border-color .15s}
.list-search:focus{border-color:var(--accent)}
.list-search::placeholder{color:var(--text3)}
+.importer-section-label{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;margin-bottom:10px}
#spotify-enrich-status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:8px 12px;margin-bottom:8px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;transition:opacity .4s}
#spotify-enrich-status.done{color:var(--accent)}
#spotify-enrich-status .enrich-spinner{width:12px;height:12px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0}