From a0644f5eab7d26c2ae3a3935dd37a57e4aba0f94 Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Wed, 14 Jan 2026 12:22:24 +0100 Subject: [PATCH 01/12] initial soulseek dl --- README.md | 48 ++++- cli/commands/download.js | 188 +++++++++++++++++- cli/lib/filenames.js | 40 +++- cli/lib/soulseek.js | 404 +++++++++++++++++++++++++++++++++++++++ cli/lib/soulseek.test.js | 218 +++++++++++++++++++++ 5 files changed, 885 insertions(+), 13 deletions(-) create mode 100644 cli/lib/soulseek.js create mode 100644 cli/lib/soulseek.test.js diff --git a/README.md b/README.md index 71d50da..6b46867 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ r4 > For the `r4 download` command to work, make sure [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) is installed on your system. +> For Soulseek downloads, you need [`slskd`](https://github.com/slskd/slskd) running. + Here's a quick overview: ```bash @@ -37,7 +39,51 @@ r4 schema | sqlite3 my.db r4 track list --channel ko002 --format sql | sqlite3 my.db ``` -Most commands support a `--format` flag to print human-readable text, json or SQL. +Most commands support a `--format` flag to print human-readable text, json or SQL. + +## Downloading + +Download tracks from YouTube (default) or Soulseek for higher quality audio. + +### YouTube (default) + +```bash +r4 download ko002 +r4 download ko002 --limit 10 +r4 download ko002 --dry-run # preview without downloading +``` + +Requires [`yt-dlp`](https://github.com/yt-dlp/yt-dlp). + +### Soulseek + +Download lossless (FLAC, WAV) or high-bitrate (320kbps) audio from Soulseek. + +Requires [slskd](https://github.com/slskd/slskd) and a [Soulseek account](https://www.slsknet.org/). + +```bash +# 1. Start slskd with your Soulseek credentials +docker run -d --name slskd \ + --network host \ + -e SLSKD_SLSK_USERNAME=your_soulseek_username \ + -e SLSKD_SLSK_PASSWORD=your_soulseek_password \ + -v ~/slskd-downloads:/app/downloads \ + slskd/slskd + +# 2. Verify slskd is connected (check web UI at http://localhost:5030) +# Default web UI login: slskd / slskd + +# 3. Download via Soulseek +r4 download ko002 --source soulseek +r4 download ko002 --source soulseek --limit 10 --verbose +r4 download ko002 --source soulseek --min-bitrate 256 +``` + +**Troubleshooting:** +- If slskd can't connect to Soulseek servers, check `docker logs slskd` +- Ensure ports 2271/2242 aren't blocked by firewall: `nc -zv vps.slsknet.org 2271` +- Some VPNs/ISPs block Soulseek - try without VPN +- The download is idempotent - run again to retry failed tracks ## Development diff --git a/cli/commands/download.js b/cli/commands/download.js index 277dbab..9a9d88c 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -1,4 +1,7 @@ -import {resolve} from 'node:path' +import {existsSync} from 'node:fs' +import {mkdir} from 'node:fs/promises' +import {join, resolve} from 'node:path' +import {load as loadConfig} from '../lib/config.js' import {getChannel, listTracks} from '../lib/data.js' import { downloadChannel, @@ -6,6 +9,11 @@ import { writeChannelImageUrl, writeTracksPlaylist } from '../lib/download.js' +import {toFilename} from '../lib/filenames.js' +import { + createClient as createSoulseekClient, + downloadTracks as downloadSoulseekTracks +} from '../lib/soulseek.js' import {parse} from '../utils.js' export default { @@ -16,6 +24,11 @@ export default { type: 'string', description: 'Output folder path (defaults to ./)' }, + source: { + type: 'string', + default: 'youtube', + description: 'Download source: youtube or soulseek' + }, limit: { type: 'number', description: 'Limit number of tracks to download' @@ -48,7 +61,24 @@ export default { concurrency: { type: 'number', default: 3, - description: 'Number of concurrent downloads' + description: + 'Number of concurrent downloads (youtube: 1-10, soulseek: 1-3)' + }, + // Soulseek-specific options + 'slskd-host': { + type: 'string', + default: 'localhost', + description: 'slskd host (default: localhost)' + }, + 'slskd-port': { + type: 'number', + default: 5030, + description: 'slskd port (default: 5030)' + }, + 'min-bitrate': { + type: 'number', + default: 320, + description: 'Minimum bitrate for lossy formats (soulseek only)' } }, @@ -60,6 +90,11 @@ export default { throw new Error('Missing channel slug') } + const source = values.source + if (source !== 'youtube' && source !== 'soulseek') { + throw new Error(`Invalid source: ${source}. Use 'youtube' or 'soulseek'`) + } + const folderPath = resolve(values.output || `./${slug}`) const dryRun = values['dry-run'] const verbose = values.verbose @@ -70,6 +105,7 @@ export default { const tracks = await listTracks({channelSlugs: [slug], limit: values.limit}) console.log(`${channel.name} (@${channel.slug})`) + console.log(`Source: ${source}`) if (dryRun) { console.log(folderPath) } @@ -77,7 +113,6 @@ export default { // Write channel context files (unless dry run) if (!dryRun) { - const {mkdir} = await import('node:fs/promises') await mkdir(folderPath, {recursive: true}) console.log(`${folderPath}/`) @@ -90,7 +125,20 @@ export default { console.log() } - // Download + // Branch based on source + if (source === 'soulseek') { + return downloadFromSoulseek(tracks, folderPath, { + dryRun, + verbose, + force: values.force, + concurrency: Math.min(values.concurrency, 3), // Soulseek is slower, limit concurrency + host: values['slskd-host'], + port: values['slskd-port'], + minBitrate: values['min-bitrate'] + }) + } + + // Default: YouTube via yt-dlp const result = await downloadChannel(tracks, folderPath, { force: values.force, retryFailed: values['retry-failed'], @@ -126,6 +174,7 @@ export default { examples: [ 'r4 download ko002', + 'r4 download ko002 --source soulseek', 'r4 download ko002 --limit 10', 'r4 download ko002 --output ./my-music', 'r4 download ko002 --dry-run', @@ -133,6 +182,137 @@ export default { 'r4 download ko002 --retry-failed', 'r4 download ko002 --no-metadata', 'r4 download ko002 --concurrency 5', + 'r4 download ko002 --source soulseek --min-bitrate 256', 'mpv ko002/tracks.m3u' ] } + +/** + * Download tracks from Soulseek via slskd + */ +async function downloadFromSoulseek(tracks, folderPath, options = {}) { + const { + dryRun = false, + verbose = false, + force = false, + concurrency = 2, + host = 'localhost', + port = 5030, + minBitrate = 320 + } = options + + // Load config for slskd credentials + const config = await loadConfig() + const slskdConfig = { + host, + port, + ...config.soulseek + } + + // Create client and verify connection + const client = createSoulseekClient(slskdConfig) + + if (!dryRun) { + console.log(`Connecting to slskd at ${host}:${port}...`) + try { + await client.checkConnection() + console.log('Connected to slskd') + console.log() + } catch (error) { + console.error(`Failed to connect to slskd: ${error.message}`) + console.error() + console.error('Make sure slskd is running:') + console.error(' docker run -d -p 5030:5030 -p 5031:5031 slskd/slskd') + console.error() + console.error( + 'Or configure connection in ~/.config/radio4000/config.json:' + ) + console.error(' { "soulseek": { "host": "localhost", "port": 5030 } }') + throw error + } + } + + // Filter tracks that already exist + const tracksDir = join(folderPath, 'tracks') + const toDownload = force + ? tracks + : tracks.filter((track) => { + // Check if any file matching this track exists + const baseFilename = toFilename(track, {source: 'soulseek'}) + // Check common extensions + for (const ext of ['flac', 'mp3', 'wav', 'ogg', 'm4a']) { + if (existsSync(join(tracksDir, `${baseFilename}.${ext}`))) { + return false + } + } + return true + }) + + const existing = tracks.length - toDownload.length + + console.log(`Total tracks: ${tracks.length}`) + console.log(` Already exists: ${existing}`) + console.log(` To download: ${toDownload.length}`) + console.log(` Concurrency: ${concurrency}`) + console.log(` Min bitrate: ${minBitrate}kbps (or lossless)`) + console.log() + + if (dryRun) { + console.log('Would search and download:') + for (const track of toDownload.slice(0, 5)) { + console.log(` ${track.title}`) + } + if (toDownload.length > 5) { + console.log(` [...${toDownload.length - 5} more]`) + } + return '' + } + + if (toDownload.length === 0) { + console.log('Nothing to download.') + return '' + } + + // Download tracks + const results = await downloadSoulseekTracks(client, toDownload, folderPath, { + concurrency, + verbose, + minBitrate + }) + + // Summary + console.log() + console.log('Summary:') + console.log(` Total: ${tracks.length}`) + console.log(` Downloaded: ${results.complete.length}`) + console.log(` Already exists: ${existing}`) + console.log(` No match found: ${results.no_match.length}`) + console.log(` Failed: ${results.failed.length}`) + + if (results.no_match.length > 0) { + console.log() + console.log( + `⚠ ${results.no_match.length} tracks had no matches on Soulseek` + ) + if (verbose) { + for (const track of results.no_match.slice(0, 5)) { + console.log(` - ${track.title}`) + } + if (results.no_match.length > 5) { + console.log(` [...${results.no_match.length - 5} more]`) + } + } + } + + if (results.failed.length > 0) { + console.log() + console.log(`⚠ ${results.failed.length} tracks failed to download`) + if (verbose) { + for (const {track, error} of results.failed.slice(0, 5)) { + console.log(` - ${track.title}: ${error}`) + } + } + } + + return '' +} diff --git a/cli/lib/filenames.js b/cli/lib/filenames.js index 4512cd9..b1a7a7a 100644 --- a/cli/lib/filenames.js +++ b/cli/lib/filenames.js @@ -8,11 +8,16 @@ import {detectMediaProvider, extractYouTubeId} from './media.js' /** * Create safe filename from track (no path, no extension) - * Format: "Track Title [youtube-id]" - * @param {Object} track - Track object with title and url + * Format depends on source: + * - youtube: "Track Title [youtube-id]" + * - soulseek: "Track Title [r4-trackid]" + * @param {Object} track - Track object with title, url, and optionally id + * @param {Object} options - Options including source * @returns {string} Safe filename */ -export function toFilename(track) { +export function toFilename(track, options = {}) { + const {source = 'youtube'} = options + if (!track.title || typeof track.title !== 'string') { throw new Error(`Invalid track title: ${JSON.stringify(track.title)}`) } @@ -22,7 +27,15 @@ export function toFilename(track) { maxLength: 180 // Leave room for ID suffix }) - // Add YouTube ID suffix if available (for uniqueness) + // Soulseek: use r4 track ID for uniqueness + if (source === 'soulseek') { + if (track.id) { + return `${cleanTitle} [r4-${track.id.slice(0, 8)}]` + } + return cleanTitle + } + + // YouTube: add YouTube ID suffix if available (for uniqueness) const ytId = extractYouTubeId(track.url) if (ytId) { return `${cleanTitle} [${ytId}]` @@ -32,16 +45,27 @@ export function toFilename(track) { } /** - * Get file extension based on media provider - * SoundCloud uses mp3, YouTube/others use m4a + * Get file extension based on media provider or source + * - Soulseek: uses extension from search result (flac, mp3, etc.) + * - SoundCloud: mp3 + * - YouTube/others: m4a * @param {Object} track - Track object with url or extension - * @returns {string} File extension (mp3 or m4a) + * @param {Object} options - Options including source + * @returns {string} File extension */ -export function toExtension(track) { +export function toExtension(track, options = {}) { + const {source = 'youtube'} = options + + // Explicit extension always wins if (track.extension) { return track.extension } + // Soulseek: default to flac (actual extension set during download) + if (source === 'soulseek') { + return 'flac' + } + const provider = detectMediaProvider(track.url) return provider === 'soundcloud' ? 'mp3' : 'm4a' } diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js new file mode 100644 index 0000000..73de22a --- /dev/null +++ b/cli/lib/soulseek.js @@ -0,0 +1,404 @@ +/** + * Soulseek download via slskd API + * Connects to a user's running slskd instance for high-quality audio downloads + * + * slskd must be running separately (Docker or native) + * Default: http://localhost:5030 + * + * @see https://github.com/slskd/slskd + */ + +import {existsSync} from 'node:fs' +import {mkdir, rename} from 'node:fs/promises' +import {extname, join} from 'node:path' +import getArtistTitle from 'get-artist-title' +import {toFilename} from './filenames.js' + +// ===== CONSTANTS ===== + +const DEFAULT_HOST = 'localhost' +const DEFAULT_PORT = 5030 +const DEFAULT_USERNAME = 'slskd' +const DEFAULT_PASSWORD = 'slskd' + +const LOSSLESS_FORMATS = ['flac', 'wav', 'ape', 'wv', 'alac'] +const MIN_BITRATE = 320 + +// Quality ranking: higher is better +const FORMAT_SCORES = { + flac: 1000, + wav: 900, + mp3: 100, + ogg: 100, + m4a: 100, + aac: 80 +} + +// ===== HELPERS ===== + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Recursively find a file by name in a directory +async function findDownloadedFile(dir, fileName) { + const {readdir} = await import('node:fs/promises') + const entries = await readdir(dir, {withFileTypes: true}) + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + const found = await findDownloadedFile(fullPath, fileName) + if (found) return found + } else if (entry.name === fileName) { + return fullPath + } + } + return null +} + +const rankResults = (files) => + files + .map((file) => ({...file, score: calculateScore(file)})) + .sort((a, b) => b.score - a.score) + +const calculateScore = (file) => { + let score = FORMAT_SCORES[file.extension] || 50 + + if (file.isLossless) score += 500 + if (!file.isLossless && file.bitrate) score += Math.min(file.bitrate, 320) + + score -= Math.min(file.queueLength * 10, 100) + score += Math.min(file.uploadSpeed / 10000, 50) + + return score +} + +const buildSearchQuery = (title) => { + // Try to parse artist - title format + const parsed = getArtistTitle(title) + if (parsed) { + const [artist, trackTitle] = parsed + // Use both artist and title for better matching + return `${artist} ${trackTitle}`.trim() + } + + // Fallback: clean up the title + return title + .replace(/\([^)]*(?:video|audio|official|remaster|remix|edit)[^)]*\)/gi, '') + .replace(/\[[^\]]*(?:video|audio|official|remaster)[^\]]*\]/gi, '') + .replace(/\s+(?:feat\.?|ft\.?|featuring)\s+/gi, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +// ===== CLIENT ===== + +/** + * Create slskd API client + * Returns an object with methods to search and download from Soulseek + */ +export function createClient(config = {}) { + const host = config.host || DEFAULT_HOST + const port = config.port || DEFAULT_PORT + const baseUrl = `http://${host}:${port}/api/v0` + let token = null + + async function authenticate() { + const username = config.username || DEFAULT_USERNAME + const password = config.password || DEFAULT_PASSWORD + + const response = await fetch(`${baseUrl}/session`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, password}) + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`slskd auth failed (${response.status}): ${text}`) + } + + const data = await response.json() + token = data.token + return token + } + + async function request(path, options = {}) { + if (!token) await authenticate() + + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + + if (response.status === 401) { + await authenticate() + return request(path, options) + } + + return response + } + + async function checkConnection() { + try { + await authenticate() + const response = await request('/application') + if (!response.ok) { + throw new Error(`slskd not responding (${response.status})`) + } + return true + } catch (error) { + if (error.cause?.code === 'ECONNREFUSED') { + throw new Error( + `Cannot connect to slskd at ${host}:${port}. Is slskd running?` + ) + } + throw error + } + } + + async function search(query, options = {}) { + const {timeout = 15000, minBitrate = MIN_BITRATE} = options + + const searchResponse = await request('/searches', { + method: 'POST', + body: JSON.stringify({ + searchText: query, + searchTimeout: timeout, + filterResponses: true, + minimumResponseFileCount: 1 + }) + }) + + if (!searchResponse.ok) { + const text = await searchResponse.text() + throw new Error(`Search failed: ${text}`) + } + + const {id: searchId} = await searchResponse.json() + + // Poll for completion + const start = Date.now() + while (Date.now() - start < timeout + 5000) { + const stateRes = await request(`/searches/${searchId}`) + if (stateRes.ok) { + const state = await stateRes.json() + if (state.state === 'Completed' || state.isComplete) break + } + await sleep(1000) + } + + // Get and filter results + const responsesRes = await request(`/searches/${searchId}/responses`) + if (!responsesRes.ok) return [] + + const responses = await responsesRes.json() + const files = [] + + for (const response of responses) { + for (const file of response.files || []) { + const ext = extname(file.filename).slice(1).toLowerCase() + const bitrate = file.bitRate || 0 + const isLossless = LOSSLESS_FORMATS.includes(ext) + + if (isLossless || bitrate >= minBitrate) { + files.push({ + username: response.username, + filename: file.filename, + size: file.size, + bitrate, + extension: ext, + isLossless, + queueLength: response.queueLength || 0, + uploadSpeed: response.uploadSpeed || 0 + }) + } + } + } + + return rankResults(files) + } + + async function queueDownload(file) { + const response = await request(`/transfers/downloads/${file.username}`, { + method: 'POST', + body: JSON.stringify([{filename: file.filename, size: file.size}]) + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Failed to queue download: ${text}`) + } + + return file.filename + } + + async function waitForDownload(username, filename, options = {}) { + const {maxWait = 300000, onProgress} = options + const start = Date.now() + + while (Date.now() - start < maxWait) { + const response = await request('/transfers/downloads') + if (!response.ok) { + await sleep(2000) + continue + } + + const data = await response.json() + + // Flatten nested structure: [{username, directories: [{files: [...]}]}] + let transfer = null + for (const user of data) { + if (user.username !== username) continue + for (const dir of user.directories || []) { + for (const file of dir.files || []) { + if (file.filename === filename || file.id === filename) { + transfer = file + break + } + } + if (transfer) break + } + if (transfer) break + } + + if (transfer) { + if (onProgress) onProgress(transfer) + + if (transfer.state === 'Completed' || transfer.state === 'Succeeded') { + // Find the actual file on disk + const downloadsDir = await getDownloadsDirectory() + const fileName = transfer.filename.split('\\').pop() + const localPath = await findDownloadedFile(downloadsDir, fileName) + return {localPath, size: transfer.size} + } + + if (['Errored', 'Rejected', 'Cancelled'].includes(transfer.state)) { + throw new Error(`Download failed: ${transfer.state}`) + } + } + + await sleep(2000) + } + + throw new Error('Download timed out') + } + + async function getDownloadsDirectory() { + const response = await request('/options') + if (response.ok) { + const opts = await response.json() + return opts.directories?.downloads || '/app/downloads' + } + return '/app/downloads' + } + + return {checkConnection, search, queueDownload, waitForDownload} +} + +// ===== DOWNLOAD FUNCTIONS ===== + +/** + * Download a single track via Soulseek + */ +export async function downloadTrack(client, track, outputDir, options = {}) { + const {verbose = false, minBitrate = MIN_BITRATE} = options + const query = buildSearchQuery(track.title) + + if (verbose) console.log(` Searching: "${query}"`) + + const results = await client.search(query, { + timeout: options.searchTimeout || 15000, + minBitrate + }) + + if (results.length === 0) { + return {status: 'no_match', track, query} + } + + const best = results[0] + + if (verbose) { + const quality = best.isLossless + ? best.extension.toUpperCase() + : `${best.bitrate}kbps ${best.extension}` + console.log(` Found: ${quality} from ${best.username}`) + } + + await client.queueDownload(best) + + const result = await client.waitForDownload(best.username, best.filename, { + maxWait: options.downloadTimeout || 300000 + }) + + // Move to output with proper naming + const baseFilename = toFilename(track, {source: 'soulseek'}) + const destPath = join(outputDir, `${baseFilename}.${best.extension}`) + + if (existsSync(result.localPath) && !existsSync(destPath)) { + await rename(result.localPath, destPath) + } + + return { + status: 'complete', + track, + path: destPath, + quality: { + format: best.extension, + bitrate: best.bitrate, + isLossless: best.isLossless + } + } +} + +/** + * Download multiple tracks with concurrency control + */ +export async function downloadTracks(client, tracks, folderPath, options = {}) { + const {verbose = false, dryRun = false} = options + + const results = {complete: [], no_match: [], failed: [], skipped: []} + const outputDir = join(folderPath, 'tracks') + + if (!dryRun) { + await mkdir(outputDir, {recursive: true}) + } + + // Soulseek only allows one concurrent search, so process sequentially + for (const [index, track] of tracks.entries()) { + const progress = `[${index + 1}/${tracks.length}]` + + if (dryRun) { + console.log(`${progress} Would search: ${track.title}`) + results.skipped.push(track) + continue + } + + try { + const result = await downloadTrack(client, track, outputDir, { + verbose, + ...options + }) + + if (result.status === 'no_match') { + console.log(`${progress} No match: ${track.title}`) + results.no_match.push(track) + } else { + const quality = result.quality.isLossless + ? result.quality.format.toUpperCase() + : `${result.quality.bitrate}kbps` + console.log(`${progress} Downloaded: ${track.title} (${quality})`) + results.complete.push(result) + } + } catch (error) { + console.error(`${progress} Failed: ${track.title}`) + if (verbose) console.error(` ${error.message}`) + results.failed.push({track, error: error.message}) + } + } + + return results +} diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js new file mode 100644 index 0000000..6827195 --- /dev/null +++ b/cli/lib/soulseek.test.js @@ -0,0 +1,218 @@ +import {expect, mock, test} from 'bun:test' +import {createClient, downloadTrack, downloadTracks} from './soulseek.js' + +// Mock fetch for testing without real slskd +const mockFetch = (responses) => { + let callIndex = 0 + return mock((url, options) => { + const response = responses[callIndex] || responses[responses.length - 1] + callIndex++ + return Promise.resolve({ + ok: response.ok !== false, + status: response.status || 200, + json: () => Promise.resolve(response.data), + text: () => Promise.resolve(response.text || '') + }) + }) +} + +test('createClient authenticates and returns token', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = mockFetch([{data: {token: 'test-token-123'}}]) + + const client = createClient({host: 'localhost', port: 5030}) + + // checkConnection calls authenticate internally + globalThis.fetch = mockFetch([ + {data: {token: 'test-token-123'}}, + {data: {version: '0.24.0'}} + ]) + + const connected = await client.checkConnection() + expect(connected).toBe(true) + + globalThis.fetch = originalFetch +}) + +test('search returns ranked results filtered by quality', async () => { + const originalFetch = globalThis.fetch + + const mockResponses = [ + // Auth + {data: {token: 'test-token'}}, + // Start search + {data: {id: 'search-123'}}, + // Poll search state (completed) + {data: {state: 'Completed', isComplete: true}}, + // Get responses + { + data: [ + { + username: 'user1', + queueLength: 0, + uploadSpeed: 100000, + files: [ + {filename: '/music/Artist - Song.flac', size: 30000000, bitRate: 0}, + {filename: '/music/Artist - Song.mp3', size: 8000000, bitRate: 320} + ] + }, + { + username: 'user2', + queueLength: 5, + uploadSpeed: 50000, + files: [ + {filename: '/music/Artist - Song.mp3', size: 7000000, bitRate: 256} + ] + } + ] + } + ] + + globalThis.fetch = mockFetch(mockResponses) + + const client = createClient() + const results = await client.search('Artist Song', { + timeout: 1000, + minBitrate: 320 + }) + + // Should have 2 results (flac and 320kbps mp3, not the 256kbps) + expect(results.length).toBe(2) + + // FLAC should be ranked first (lossless) + expect(results[0].extension).toBe('flac') + expect(results[0].isLossless).toBe(true) + + // 320kbps mp3 second + expect(results[1].extension).toBe('mp3') + expect(results[1].bitrate).toBe(320) + + globalThis.fetch = originalFetch +}) + +test('buildSearchQuery cleans up track titles', async () => { + // Test the search query building by checking what gets passed to the API + const originalFetch = globalThis.fetch + let capturedSearchText = null + + globalThis.fetch = mock((url, options) => { + if (url.includes('/searches/text') && options?.body) { + const body = JSON.parse(options.body) + capturedSearchText = body.searchText + } + + // Return appropriate response based on URL + if (url.includes('/session')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({token: 'test-token'}), + text: () => Promise.resolve('') + }) + } + if (url.includes('/searches/text')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({id: 'test-search'}), + text: () => Promise.resolve('') + }) + } + if (url.includes('/searches/test-search/responses')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + text: () => Promise.resolve('') + }) + } + if (url.includes('/searches/test-search')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({state: 'Completed', isComplete: true}), + text: () => Promise.resolve('') + }) + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + text: () => Promise.resolve('') + }) + }) + + const client = createClient() + + // Search with a messy title + await client.search('Artist - Song (Official Video) [Remastered]', { + timeout: 100 + }) + + // Should have cleaned up the title + expect(capturedSearchText).toBe('Artist - Song') + + globalThis.fetch = originalFetch +}) + +test('downloadTrack returns no_match when no results', async () => { + const originalFetch = globalThis.fetch + + globalThis.fetch = mockFetch([ + {data: {token: 'test-token'}}, + {data: {id: 'search-123'}}, + {data: {state: 'Completed'}}, + {data: []} // No results + ]) + + const client = createClient() + const track = {id: 'track-1', title: 'Unknown Artist - Rare Song'} + + const result = await downloadTrack(client, track, '/tmp/test', {}) + + expect(result.status).toBe('no_match') + expect(result.track).toBe(track) + + globalThis.fetch = originalFetch +}) + +test('quality scoring prefers lossless over high bitrate', () => { + // This tests the internal scoring logic indirectly through search results ordering + const originalFetch = globalThis.fetch + + const mockResponses = [ + {data: {token: 'test-token'}}, + {data: {id: 'search-123'}}, + {data: {state: 'Completed'}}, + { + data: [ + { + username: 'user1', + queueLength: 0, + uploadSpeed: 100000, + files: [ + // High bitrate MP3 + {filename: '/music/song.mp3', size: 15000000, bitRate: 320} + ] + }, + { + username: 'user2', + queueLength: 0, + uploadSpeed: 100000, + files: [ + // FLAC (lossless) + {filename: '/music/song.flac', size: 30000000, bitRate: 0} + ] + } + ] + } + ] + + globalThis.fetch = mockFetch(mockResponses) + + const client = createClient() + + return client.search('test', {timeout: 100}).then((results) => { + // FLAC should be first despite being from user2 + expect(results[0].extension).toBe('flac') + expect(results[0].isLossless).toBe(true) + expect(results[1].extension).toBe('mp3') + + globalThis.fetch = originalFetch + }) +}) From b86119eef8f613e431285873c4256182ab327cd6 Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Wed, 14 Jan 2026 21:31:17 +0100 Subject: [PATCH 02/12] improve soulseek --- cli/commands/download.js | 89 +++++++++++++++--------- cli/lib/config.js | 16 ++++- cli/lib/soulseek.js | 142 +++++++++++++++++++++++++-------------- cli/lib/soulseek.test.js | 87 +++++++++--------------- 4 files changed, 194 insertions(+), 140 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index 9a9d88c..e87eaf1 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -5,8 +5,10 @@ import {load as loadConfig} from '../lib/config.js' import {getChannel, listTracks} from '../lib/data.js' import { downloadChannel, + readFailedTrackIds, writeChannelAbout, writeChannelImageUrl, + writeFailures, writeTracksPlaylist } from '../lib/download.js' import {toFilename} from '../lib/filenames.js' @@ -64,21 +66,18 @@ export default { description: 'Number of concurrent downloads (youtube: 1-10, soulseek: 1-3)' }, - // Soulseek-specific options + // Soulseek-specific options (defaults come from config, then fallback) 'slskd-host': { type: 'string', - default: 'localhost', - description: 'slskd host (default: localhost)' + description: 'slskd host (default: from config or localhost)' }, 'slskd-port': { type: 'number', - default: 5030, - description: 'slskd port (default: 5030)' + description: 'slskd port (default: from config or 5030)' }, 'min-bitrate': { type: 'number', - default: 320, - description: 'Minimum bitrate for lossy formats (soulseek only)' + description: 'Minimum bitrate for lossy formats (default: 320)' } }, @@ -111,18 +110,20 @@ export default { } console.log() - // Write channel context files (unless dry run) + // Ensure output folder exists if (!dryRun) { await mkdir(folderPath, {recursive: true}) - console.log(`${folderPath}/`) - await writeChannelAbout(channel, tracks, folderPath, {verbose}) - console.log(`├── ${channel.slug}.txt`) - await writeChannelImageUrl(channel, folderPath, {verbose}) - console.log('├── image.url') - await writeTracksPlaylist(tracks, folderPath, {verbose}) - console.log(`└── tracks.m3u (try: mpv ${folderPath}/tracks.m3u)`) - console.log() + if (!noMetadata) { + console.log(`${folderPath}/`) + await writeChannelAbout(channel, tracks, folderPath, {verbose}) + console.log(`├── ${channel.slug}.txt`) + await writeChannelImageUrl(channel, folderPath, {verbose}) + console.log('├── image.url') + await writeTracksPlaylist(tracks, folderPath, {verbose}) + console.log(`└── tracks.m3u (try: mpv ${folderPath}/tracks.m3u)`) + console.log() + } } // Branch based on source @@ -131,6 +132,7 @@ export default { dryRun, verbose, force: values.force, + retryFailed: values['retry-failed'], concurrency: Math.min(values.concurrency, 3), // Soulseek is slower, limit concurrency host: values['slskd-host'], port: values['slskd-port'], @@ -195,25 +197,28 @@ async function downloadFromSoulseek(tracks, folderPath, options = {}) { dryRun = false, verbose = false, force = false, + retryFailed = false, concurrency = 2, - host = 'localhost', - port = 5030, + host, + port, minBitrate = 320 } = options - // Load config for slskd credentials + // Load config for slskd credentials (CLI args override config, then defaults) const config = await loadConfig() const slskdConfig = { - host, - port, - ...config.soulseek + ...config.soulseek, + host: host ?? config.soulseek?.host ?? 'localhost', + port: port ?? config.soulseek?.port ?? 5030 } + const effectiveHost = slskdConfig.host + const effectivePort = slskdConfig.port // Create client and verify connection const client = createSoulseekClient(slskdConfig) if (!dryRun) { - console.log(`Connecting to slskd at ${host}:${port}...`) + console.log(`Connecting to slskd at ${effectiveHost}:${effectivePort}...`) try { await client.checkConnection() console.log('Connected to slskd') @@ -233,25 +238,38 @@ async function downloadFromSoulseek(tracks, folderPath, options = {}) { } // Filter tracks that already exist - const tracksDir = join(folderPath, 'tracks') + const soulseekDir = join(folderPath, 'soulseek') + const failedIds = retryFailed ? new Set() : readFailedTrackIds(folderPath) + // DJ-compatible formats only (CDJ/Rekordbox/Traktor/Serato) + const supportedExtensions = ['flac', 'wav', 'mp3', 'ogg'] + const hasExistingFile = (track) => { + const baseFilename = toFilename(track, {source: 'soulseek'}) + for (const ext of supportedExtensions) { + if (existsSync(join(soulseekDir, `${baseFilename}.${ext}`))) { + return true + } + } + return false + } const toDownload = force ? tracks : tracks.filter((track) => { - // Check if any file matching this track exists - const baseFilename = toFilename(track, {source: 'soulseek'}) - // Check common extensions - for (const ext of ['flac', 'mp3', 'wav', 'ogg', 'm4a']) { - if (existsSync(join(tracksDir, `${baseFilename}.${ext}`))) { - return false - } + if (failedIds.has(track.id)) { + return false } - return true + return !hasExistingFile(track) }) - const existing = tracks.length - toDownload.length + const existing = tracks.filter((track) => hasExistingFile(track)).length + const previouslyFailed = tracks.filter((track) => + failedIds.has(track.id) + ).length console.log(`Total tracks: ${tracks.length}`) console.log(` Already exists: ${existing}`) + if (previouslyFailed > 0) { + console.log(` Previously failed: ${previouslyFailed}`) + } console.log(` To download: ${toDownload.length}`) console.log(` Concurrency: ${concurrency}`) console.log(` Min bitrate: ${minBitrate}kbps (or lossless)`) @@ -289,6 +307,10 @@ async function downloadFromSoulseek(tracks, folderPath, options = {}) { console.log(` No match found: ${results.no_match.length}`) console.log(` Failed: ${results.failed.length}`) + if (results.failed.length > 0) { + await writeFailures(results.failed, folderPath, {verbose}) + } + if (results.no_match.length > 0) { console.log() console.log( @@ -307,6 +329,7 @@ async function downloadFromSoulseek(tracks, folderPath, options = {}) { if (results.failed.length > 0) { console.log() console.log(`⚠ ${results.failed.length} tracks failed to download`) + console.log(` See: ${folderPath}/failures.jsonl`) if (verbose) { for (const {track, error} of results.failed.slice(0, 5)) { console.log(` - ${track.title}: ${error}`) diff --git a/cli/lib/config.js b/cli/lib/config.js index b20fa9e..15a94f9 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -5,7 +5,14 @@ import {join} from 'node:path' const configPath = join(homedir(), '.config', 'radio4000', 'config.json') const defaults = { - auth: {session: null} + auth: {session: null}, + soulseek: { + // slskd connection settings + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd' + } } /** Load config from disk, return defaults if missing */ @@ -32,13 +39,16 @@ export async function save(config) { return config } -/** Update config with partial changes (deep merges auth) */ +/** Update config with partial changes (deep merges auth and soulseek) */ export async function update(changes) { const config = await load() const merged = { ...config, ...changes, - auth: changes.auth ? {...config.auth, ...changes.auth} : config.auth + auth: changes.auth ? {...config.auth, ...changes.auth} : config.auth, + soulseek: changes.soulseek + ? {...config.soulseek, ...changes.soulseek} + : config.soulseek } return save(merged) } diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 73de22a..3d1dcdc 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -21,17 +21,18 @@ const DEFAULT_PORT = 5030 const DEFAULT_USERNAME = 'slskd' const DEFAULT_PASSWORD = 'slskd' -const LOSSLESS_FORMATS = ['flac', 'wav', 'ape', 'wv', 'alac'] +// DJ-compatible formats (CDJ/Rekordbox/Traktor/Serato compatible) +const LOSSLESS_FORMATS = ['flac', 'wav'] +const LOSSY_FORMATS = ['mp3', 'ogg'] +const ALLOWED_FORMATS = [...LOSSLESS_FORMATS, ...LOSSY_FORMATS] const MIN_BITRATE = 320 -// Quality ranking: higher is better +// Quality ranking: higher is better (DJ-compatible only) const FORMAT_SCORES = { flac: 1000, wav: 900, mp3: 100, - ogg: 100, - m4a: 100, - aac: 80 + ogg: 100 } // ===== HELPERS ===== @@ -73,21 +74,16 @@ const calculateScore = (file) => { } const buildSearchQuery = (title) => { - // Try to parse artist - title format + // get-artist-title handles cleanup: strips (Official Video), [Remastered], etc. + // but keeps DJ-relevant markers like (Dub Mix) const parsed = getArtistTitle(title) if (parsed) { const [artist, trackTitle] = parsed - // Use both artist and title for better matching return `${artist} ${trackTitle}`.trim() } - // Fallback: clean up the title - return title - .replace(/\([^)]*(?:video|audio|official|remaster|remix|edit)[^)]*\)/gi, '') - .replace(/\[[^\]]*(?:video|audio|official|remaster)[^\]]*\]/gi, '') - .replace(/\s+(?:feat\.?|ft\.?|featuring)\s+/gi, ' ') - .replace(/\s+/g, ' ') - .trim() + // No "Artist - Title" format found, use title as-is + return title.trim() } // ===== CLIENT ===== @@ -175,7 +171,7 @@ export function createClient(config = {}) { if (!searchResponse.ok) { const text = await searchResponse.text() - throw new Error(`Search failed: ${text}`) + throw new Error(`search failed: ${text}`) } const {id: searchId} = await searchResponse.json() @@ -201,9 +197,15 @@ export function createClient(config = {}) { for (const response of responses) { for (const file of response.files || []) { const ext = extname(file.filename).slice(1).toLowerCase() + if (!ext) continue + + // Only DJ-compatible formats + if (!ALLOWED_FORMATS.includes(ext)) continue + const bitrate = file.bitRate || 0 const isLossless = LOSSLESS_FORMATS.includes(ext) + // Lossless: always accept. Lossy: require minimum bitrate if (isLossless || bitrate >= minBitrate) { files.push({ username: response.username, @@ -230,7 +232,7 @@ export function createClient(config = {}) { if (!response.ok) { const text = await response.text() - throw new Error(`Failed to queue download: ${text}`) + throw new Error(`failed to queue download: ${text}`) } return file.filename @@ -271,29 +273,40 @@ export function createClient(config = {}) { if (transfer.state === 'Completed' || transfer.state === 'Succeeded') { // Find the actual file on disk const downloadsDir = await getDownloadsDirectory() - const fileName = transfer.filename.split('\\').pop() + const fileName = transfer.filename.split(/[\\/]/).pop() + if (!fileName) { + throw new Error('downloaded file name is empty') + } const localPath = await findDownloadedFile(downloadsDir, fileName) + if (!localPath) { + throw new Error(`downloaded file not found: ${fileName}`) + } return {localPath, size: transfer.size} } if (['Errored', 'Rejected', 'Cancelled'].includes(transfer.state)) { - throw new Error(`Download failed: ${transfer.state}`) + throw new Error(`download failed: ${transfer.state}`) } } await sleep(2000) } - throw new Error('Download timed out') + throw new Error('download timed out') } async function getDownloadsDirectory() { const response = await request('/options') - if (response.ok) { - const opts = await response.json() - return opts.directories?.downloads || '/app/downloads' + if (!response.ok) { + throw new Error( + 'failed to get slskd options - cannot determine downloads directory' + ) } - return '/app/downloads' + const opts = await response.json() + if (!opts.directories?.downloads) { + throw new Error('slskd downloads directory not configured') + } + return opts.directories.downloads } return {checkConnection, search, queueDownload, waitForDownload} @@ -358,47 +371,78 @@ export async function downloadTrack(client, track, outputDir, options = {}) { * Download multiple tracks with concurrency control */ export async function downloadTracks(client, tracks, folderPath, options = {}) { - const {verbose = false, dryRun = false} = options + const {verbose = false, dryRun = false, concurrency = 1} = options const results = {complete: [], no_match: [], failed: [], skipped: []} - const outputDir = join(folderPath, 'tracks') + const outputDir = join(folderPath, 'soulseek') if (!dryRun) { await mkdir(outputDir, {recursive: true}) } - // Soulseek only allows one concurrent search, so process sequentially - for (const [index, track] of tracks.entries()) { - const progress = `[${index + 1}/${tracks.length}]` - - if (dryRun) { + if (dryRun) { + for (const [index, track] of tracks.entries()) { + const progress = `[${index + 1}/${tracks.length}]` console.log(`${progress} Would search: ${track.title}`) results.skipped.push(track) - continue } + return results + } - try { - const result = await downloadTrack(client, track, outputDir, { - verbose, - ...options + // Serialize searches while allowing concurrent downloads. + let searchQueue = Promise.resolve() + const clientWithSerializedSearch = { + ...client, + search: async (...args) => { + const run = searchQueue.then(() => client.search(...args)) + searchQueue = run.catch((err) => { + if (verbose) console.error('Search queue error:', err.message) }) + return run + } + } - if (result.status === 'no_match') { - console.log(`${progress} No match: ${track.title}`) - results.no_match.push(track) - } else { - const quality = result.quality.isLossless - ? result.quality.format.toUpperCase() - : `${result.quality.bitrate}kbps` - console.log(`${progress} Downloaded: ${track.title} (${quality})`) - results.complete.push(result) + const limit = Math.max(1, concurrency) + let nextIndex = 0 + + const workers = Array.from({length: limit}, async () => { + while (true) { + const index = nextIndex++ + if (index >= tracks.length) return + + const track = tracks[index] + const progress = `[${index + 1}/${tracks.length}]` + + try { + const result = await downloadTrack( + clientWithSerializedSearch, + track, + outputDir, + { + verbose, + ...options + } + ) + + if (result.status === 'no_match') { + console.log(`${progress} No match: ${track.title}`) + results.no_match.push(track) + } else { + const quality = result.quality.isLossless + ? result.quality.format.toUpperCase() + : `${result.quality.bitrate}kbps` + console.log(`${progress} Downloaded: ${track.title} (${quality})`) + results.complete.push(result) + } + } catch (error) { + console.error(`${progress} Failed: ${track.title}`) + if (verbose) console.error(` ${error.message}`) + results.failed.push({track, error: error.message}) } - } catch (error) { - console.error(`${progress} Failed: ${track.title}`) - if (verbose) console.error(` ${error.message}`) - results.failed.push({track, error: error.message}) } - } + }) + + await Promise.all(workers) return results } diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js index 6827195..b67abd3 100644 --- a/cli/lib/soulseek.test.js +++ b/cli/lib/soulseek.test.js @@ -1,10 +1,10 @@ import {expect, mock, test} from 'bun:test' -import {createClient, downloadTrack, downloadTracks} from './soulseek.js' +import {createClient, downloadTrack} from './soulseek.js' // Mock fetch for testing without real slskd const mockFetch = (responses) => { let callIndex = 0 - return mock((url, options) => { + return mock((_url, _options) => { const response = responses[callIndex] || responses[responses.length - 1] callIndex++ return Promise.resolve({ @@ -90,64 +90,38 @@ test('search returns ranked results filtered by quality', async () => { globalThis.fetch = originalFetch }) -test('buildSearchQuery cleans up track titles', async () => { - // Test the search query building by checking what gets passed to the API - const originalFetch = globalThis.fetch - let capturedSearchText = null - - globalThis.fetch = mock((url, options) => { - if (url.includes('/searches/text') && options?.body) { - const body = JSON.parse(options.body) - capturedSearchText = body.searchText - } - - // Return appropriate response based on URL - if (url.includes('/session')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({token: 'test-token'}), - text: () => Promise.resolve('') - }) - } - if (url.includes('/searches/text')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({id: 'test-search'}), - text: () => Promise.resolve('') - }) - } - if (url.includes('/searches/test-search/responses')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve([]), - text: () => Promise.resolve('') - }) - } - if (url.includes('/searches/test-search')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({state: 'Completed', isComplete: true}), - text: () => Promise.resolve('') - }) - } - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - text: () => Promise.resolve('') +test('buildSearchQuery uses get-artist-title to parse and clean', async () => { + let capturedQuery = null + const client = { + search: mock(async (query) => { + capturedQuery = query + return [] }) - }) + } - const client = createClient() + // get-artist-title strips (Official Video) and [Remastered] but keeps (Dub Mix) + const track = { + id: 'track-1', + title: 'Artist - Song (Official Video) [Remastered]' + } + await downloadTrack(client, track, '/tmp/test', {}) - // Search with a messy title - await client.search('Artist - Song (Official Video) [Remastered]', { - timeout: 100 - }) + expect(capturedQuery).toBe('Artist Song') +}) - // Should have cleaned up the title - expect(capturedSearchText).toBe('Artist - Song') +test('buildSearchQuery preserves DJ-relevant markers', async () => { + let capturedQuery = null + const client = { + search: mock(async (query) => { + capturedQuery = query + return [] + }) + } - globalThis.fetch = originalFetch + const track = {id: 'track-2', title: 'Artist - Song (Dub Mix)'} + await downloadTrack(client, track, '/tmp/test', {}) + + expect(capturedQuery).toBe('Artist Song (Dub Mix)') }) test('downloadTrack returns no_match when no results', async () => { @@ -216,3 +190,6 @@ test('quality scoring prefers lossless over high bitrate', () => { globalThis.fetch = originalFetch }) }) + +// Note: Title cleaning is handled by get-artist-title library +// It strips (Official Video), [Remastered], etc. but keeps DJ-relevant markers like (Dub Mix) From 7fff0d926d532f28c0a1ff80dfc9b366ed2ac0d0 Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Thu, 15 Jan 2026 10:28:03 +0100 Subject: [PATCH 03/12] fix soulseek base dir --- cli/commands/download.js | 37 +++++++++++++++++++++++++--------- cli/lib/config.js | 4 +++- cli/lib/soulseek.js | 43 ++++++++++++++++++++++++++++------------ cli/lib/soulseek.test.js | 5 +++-- 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index e87eaf1..7f3252c 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -78,6 +78,11 @@ export default { 'min-bitrate': { type: 'number', description: 'Minimum bitrate for lossy formats (default: 320)' + }, + 'slskd-downloads-dir': { + type: 'string', + description: + 'Override slskd downloads directory (for Docker setups where host path differs)' } }, @@ -94,7 +99,10 @@ export default { throw new Error(`Invalid source: ${source}. Use 'youtube' or 'soulseek'`) } - const folderPath = resolve(values.output || `./${slug}`) + // Load config for default paths + const config = await loadConfig() + const baseDir = values.output || config.downloadsDir || '.' + const folderPath = resolve(join(baseDir, slug)) const dryRun = values['dry-run'] const verbose = values.verbose const noMetadata = values['no-metadata'] @@ -136,7 +144,8 @@ export default { concurrency: Math.min(values.concurrency, 3), // Soulseek is slower, limit concurrency host: values['slskd-host'], port: values['slskd-port'], - minBitrate: values['min-bitrate'] + minBitrate: values['min-bitrate'], + downloadsDir: values['slskd-downloads-dir'] }) } @@ -175,16 +184,21 @@ export default { }, examples: [ + '# YouTube downloads (default)', 'r4 download ko002', - 'r4 download ko002 --source soulseek', 'r4 download ko002 --limit 10', 'r4 download ko002 --output ./my-music', 'r4 download ko002 --dry-run', - 'r4 download ko002 --force', - 'r4 download ko002 --retry-failed', - 'r4 download ko002 --no-metadata', - 'r4 download ko002 --concurrency 5', + '', + '# Soulseek downloads (requires slskd)', + '# Start slskd: docker run -d --network host -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass -v ~/Music/slskd:/app/downloads slskd/slskd', + 'r4 download ko002 --source soulseek --slskd-downloads-dir ~/Music/slskd', 'r4 download ko002 --source soulseek --min-bitrate 256', + '', + '# Output structure:', + '# ko002/tracks/ - YouTube downloads (mp3/opus)', + '# ko002/soulseek/ - Soulseek downloads (flac/wav/mp3)', + '', 'mpv ko002/tracks.m3u' ] } @@ -201,15 +215,20 @@ async function downloadFromSoulseek(tracks, folderPath, options = {}) { concurrency = 2, host, port, - minBitrate = 320 + minBitrate = 320, + downloadsDir } = options // Load config for slskd credentials (CLI args override config, then defaults) const config = await loadConfig() + // slskd downloads dir: CLI flag > config.downloadsDir/slskd > none (uses slskd API) + const slskdDownloadsDir = + downloadsDir ?? (config.downloadsDir ? join(config.downloadsDir, 'slskd') : null) const slskdConfig = { ...config.soulseek, host: host ?? config.soulseek?.host ?? 'localhost', - port: port ?? config.soulseek?.port ?? 5030 + port: port ?? config.soulseek?.port ?? 5030, + downloadsDir: slskdDownloadsDir } const effectiveHost = slskdConfig.host const effectivePort = slskdConfig.port diff --git a/cli/lib/config.js b/cli/lib/config.js index 15a94f9..4cafe31 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -6,8 +6,10 @@ const configPath = join(homedir(), '.config', 'radio4000', 'config.json') const defaults = { auth: {session: null}, + // Base directory for all downloads (channels saved as subfolders) + downloadsDir: null, + // slskd connection settings (optional, defaults work for local Docker) soulseek: { - // slskd connection settings host: 'localhost', port: 5030, username: 'slskd', diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 3d1dcdc..e9f0788 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -9,7 +9,7 @@ */ import {existsSync} from 'node:fs' -import {mkdir, rename} from 'node:fs/promises' +import {copyFile, mkdir, unlink} from 'node:fs/promises' import {extname, join} from 'node:path' import getArtistTitle from 'get-artist-title' import {toFilename} from './filenames.js' @@ -74,16 +74,15 @@ const calculateScore = (file) => { } const buildSearchQuery = (title) => { - // get-artist-title handles cleanup: strips (Official Video), [Remastered], etc. - // but keeps DJ-relevant markers like (Dub Mix) + // get-artist-title handles basic cleanup const parsed = getArtistTitle(title) - if (parsed) { - const [artist, trackTitle] = parsed - return `${artist} ${trackTitle}`.trim() - } + let query = parsed ? `${parsed[0]} ${parsed[1]}` : title + + // Strip parenthetical/bracketed content for search - Soulseek search + // works better with simpler queries, and results include all versions anyway + query = query.replace(/\s*[[(][^\])]*[\])]/g, '') - // No "Artist - Title" format found, use title as-is - return title.trim() + return query.trim() } // ===== CLIENT ===== @@ -270,7 +269,11 @@ export function createClient(config = {}) { if (transfer) { if (onProgress) onProgress(transfer) - if (transfer.state === 'Completed' || transfer.state === 'Succeeded') { + // State can be "Completed", "Succeeded", or "Completed, Succeeded" + const isComplete = + transfer.state?.includes('Completed') || + transfer.state?.includes('Succeeded') + if (isComplete) { // Find the actual file on disk const downloadsDir = await getDownloadsDirectory() const fileName = transfer.filename.split(/[\\/]/).pop() @@ -284,7 +287,11 @@ export function createClient(config = {}) { return {localPath, size: transfer.size} } - if (['Errored', 'Rejected', 'Cancelled'].includes(transfer.state)) { + const isFailed = + transfer.state?.includes('Errored') || + transfer.state?.includes('Rejected') || + transfer.state?.includes('Cancelled') + if (isFailed) { throw new Error(`download failed: ${transfer.state}`) } } @@ -296,6 +303,10 @@ export function createClient(config = {}) { } async function getDownloadsDirectory() { + // Allow override for Docker setups where container path differs from host path + if (config.downloadsDir) { + return config.downloadsDir + } const response = await request('/options') if (!response.ok) { throw new Error( @@ -347,12 +358,18 @@ export async function downloadTrack(client, track, outputDir, options = {}) { maxWait: options.downloadTimeout || 300000 }) - // Move to output with proper naming + // Copy to output with proper naming (copy instead of rename for Docker/permission compat) const baseFilename = toFilename(track, {source: 'soulseek'}) const destPath = join(outputDir, `${baseFilename}.${best.extension}`) if (existsSync(result.localPath) && !existsSync(destPath)) { - await rename(result.localPath, destPath) + await copyFile(result.localPath, destPath) + // Try to remove source, but don't fail if we can't (Docker permissions) + try { + await unlink(result.localPath) + } catch { + // Ignore - file will stay in slskd downloads folder + } } return { diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js index b67abd3..49c8e20 100644 --- a/cli/lib/soulseek.test.js +++ b/cli/lib/soulseek.test.js @@ -109,7 +109,7 @@ test('buildSearchQuery uses get-artist-title to parse and clean', async () => { expect(capturedQuery).toBe('Artist Song') }) -test('buildSearchQuery preserves DJ-relevant markers', async () => { +test('buildSearchQuery strips all parenthetical content for better Soulseek search', async () => { let capturedQuery = null const client = { search: mock(async (query) => { @@ -121,7 +121,8 @@ test('buildSearchQuery preserves DJ-relevant markers', async () => { const track = {id: 'track-2', title: 'Artist - Song (Dub Mix)'} await downloadTrack(client, track, '/tmp/test', {}) - expect(capturedQuery).toBe('Artist Song (Dub Mix)') + // Parenthetical content stripped for search - Soulseek works better with simpler queries + expect(capturedQuery).toBe('Artist Song') }) test('downloadTrack returns no_match when no results', async () => { From 81f79d8d022727732bcca96d96e492740a9e0da4 Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Thu, 15 Jan 2026 12:20:56 +0100 Subject: [PATCH 04/12] improve soulseek downloads --- cli/commands/download.js | 229 ++++----------------------------------- cli/lib/config.js | 12 +- cli/lib/soulseek.js | 175 ++++++++++++++++++++++++++++-- cli/lib/soulseek.test.js | 6 +- 4 files changed, 197 insertions(+), 225 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index 7f3252c..6abbc36 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -1,21 +1,14 @@ -import {existsSync} from 'node:fs' import {mkdir} from 'node:fs/promises' import {join, resolve} from 'node:path' import {load as loadConfig} from '../lib/config.js' import {getChannel, listTracks} from '../lib/data.js' import { - downloadChannel, - readFailedTrackIds, + downloadChannel as downloadYouTube, writeChannelAbout, writeChannelImageUrl, - writeFailures, writeTracksPlaylist } from '../lib/download.js' -import {toFilename} from '../lib/filenames.js' -import { - createClient as createSoulseekClient, - downloadTracks as downloadSoulseekTracks -} from '../lib/soulseek.js' +import {downloadChannel as downloadSoulseek} from '../lib/soulseek.js' import {parse} from '../utils.js' export default { @@ -24,7 +17,7 @@ export default { options: { output: { type: 'string', - description: 'Output folder path (defaults to ./)' + description: 'Output folder path (defaults to config.downloadsDir/)' }, source: { type: 'string', @@ -66,23 +59,16 @@ export default { description: 'Number of concurrent downloads (youtube: 1-10, soulseek: 1-3)' }, - // Soulseek-specific options (defaults come from config, then fallback) - 'slskd-host': { - type: 'string', - description: 'slskd host (default: from config or localhost)' - }, - 'slskd-port': { - type: 'number', - description: 'slskd port (default: from config or 5030)' - }, + // Soulseek-specific options + 'slskd-host': {type: 'string', description: 'slskd host'}, + 'slskd-port': {type: 'number', description: 'slskd port'}, 'min-bitrate': { type: 'number', description: 'Minimum bitrate for lossy formats (default: 320)' }, 'slskd-downloads-dir': { type: 'string', - description: - 'Override slskd downloads directory (for Docker setups where host path differs)' + description: 'slskd downloads directory (for Docker)' } }, @@ -90,19 +76,18 @@ export default { const {values, positionals} = parse(argv, this.options) const slug = positionals[0] - if (!slug) { - throw new Error('Missing channel slug') - } + if (!slug) throw new Error('Missing channel slug') const source = values.source if (source !== 'youtube' && source !== 'soulseek') { throw new Error(`Invalid source: ${source}. Use 'youtube' or 'soulseek'`) } - // Load config for default paths + // Resolve output path from config const config = await loadConfig() const baseDir = values.output || config.downloadsDir || '.' const folderPath = resolve(join(baseDir, slug)) + const dryRun = values['dry-run'] const verbose = values.verbose const noMetadata = values['no-metadata'] @@ -113,15 +98,12 @@ export default { console.log(`${channel.name} (@${channel.slug})`) console.log(`Source: ${source}`) - if (dryRun) { - console.log(folderPath) - } + if (dryRun) console.log(folderPath) console.log() - // Ensure output folder exists + // Write metadata files if (!dryRun) { await mkdir(folderPath, {recursive: true}) - if (!noMetadata) { console.log(`${folderPath}/`) await writeChannelAbout(channel, tracks, folderPath, {verbose}) @@ -134,23 +116,24 @@ export default { } } - // Branch based on source + // Download via source if (source === 'soulseek') { - return downloadFromSoulseek(tracks, folderPath, { + await downloadSoulseek(tracks, folderPath, { dryRun, verbose, force: values.force, retryFailed: values['retry-failed'], - concurrency: Math.min(values.concurrency, 3), // Soulseek is slower, limit concurrency + concurrency: Math.min(values.concurrency, 3), host: values['slskd-host'], port: values['slskd-port'], minBitrate: values['min-bitrate'], downloadsDir: values['slskd-downloads-dir'] }) + return '' } - // Default: YouTube via yt-dlp - const result = await downloadChannel(tracks, folderPath, { + // YouTube via yt-dlp + const result = await downloadYouTube(tracks, folderPath, { force: values.force, retryFailed: values['retry-failed'], dryRun, @@ -159,7 +142,6 @@ export default { concurrency: values.concurrency }) - // Only show summary and failures for actual downloads, not dry runs if (!dryRun) { console.log() console.log('Summary:') @@ -179,182 +161,17 @@ export default { } } - // Don't return data - all output already printed above return '' }, examples: [ - '# YouTube downloads (default)', + '# YouTube (default)', 'r4 download ko002', - 'r4 download ko002 --limit 10', - 'r4 download ko002 --output ./my-music', - 'r4 download ko002 --dry-run', - '', - '# Soulseek downloads (requires slskd)', - '# Start slskd: docker run -d --network host -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass -v ~/Music/slskd:/app/downloads slskd/slskd', - 'r4 download ko002 --source soulseek --slskd-downloads-dir ~/Music/slskd', - 'r4 download ko002 --source soulseek --min-bitrate 256', + 'r4 download ko002 --limit 10 --dry-run', '', - '# Output structure:', - '# ko002/tracks/ - YouTube downloads (mp3/opus)', - '# ko002/soulseek/ - Soulseek downloads (flac/wav/mp3)', + '# Soulseek (requires slskd)', + 'r4 download ko002 --source soulseek', '', - 'mpv ko002/tracks.m3u' + '# Output: ko002/tracks/ (youtube), ko002/soulseek/ (soulseek)' ] } - -/** - * Download tracks from Soulseek via slskd - */ -async function downloadFromSoulseek(tracks, folderPath, options = {}) { - const { - dryRun = false, - verbose = false, - force = false, - retryFailed = false, - concurrency = 2, - host, - port, - minBitrate = 320, - downloadsDir - } = options - - // Load config for slskd credentials (CLI args override config, then defaults) - const config = await loadConfig() - // slskd downloads dir: CLI flag > config.downloadsDir/slskd > none (uses slskd API) - const slskdDownloadsDir = - downloadsDir ?? (config.downloadsDir ? join(config.downloadsDir, 'slskd') : null) - const slskdConfig = { - ...config.soulseek, - host: host ?? config.soulseek?.host ?? 'localhost', - port: port ?? config.soulseek?.port ?? 5030, - downloadsDir: slskdDownloadsDir - } - const effectiveHost = slskdConfig.host - const effectivePort = slskdConfig.port - - // Create client and verify connection - const client = createSoulseekClient(slskdConfig) - - if (!dryRun) { - console.log(`Connecting to slskd at ${effectiveHost}:${effectivePort}...`) - try { - await client.checkConnection() - console.log('Connected to slskd') - console.log() - } catch (error) { - console.error(`Failed to connect to slskd: ${error.message}`) - console.error() - console.error('Make sure slskd is running:') - console.error(' docker run -d -p 5030:5030 -p 5031:5031 slskd/slskd') - console.error() - console.error( - 'Or configure connection in ~/.config/radio4000/config.json:' - ) - console.error(' { "soulseek": { "host": "localhost", "port": 5030 } }') - throw error - } - } - - // Filter tracks that already exist - const soulseekDir = join(folderPath, 'soulseek') - const failedIds = retryFailed ? new Set() : readFailedTrackIds(folderPath) - // DJ-compatible formats only (CDJ/Rekordbox/Traktor/Serato) - const supportedExtensions = ['flac', 'wav', 'mp3', 'ogg'] - const hasExistingFile = (track) => { - const baseFilename = toFilename(track, {source: 'soulseek'}) - for (const ext of supportedExtensions) { - if (existsSync(join(soulseekDir, `${baseFilename}.${ext}`))) { - return true - } - } - return false - } - const toDownload = force - ? tracks - : tracks.filter((track) => { - if (failedIds.has(track.id)) { - return false - } - return !hasExistingFile(track) - }) - - const existing = tracks.filter((track) => hasExistingFile(track)).length - const previouslyFailed = tracks.filter((track) => - failedIds.has(track.id) - ).length - - console.log(`Total tracks: ${tracks.length}`) - console.log(` Already exists: ${existing}`) - if (previouslyFailed > 0) { - console.log(` Previously failed: ${previouslyFailed}`) - } - console.log(` To download: ${toDownload.length}`) - console.log(` Concurrency: ${concurrency}`) - console.log(` Min bitrate: ${minBitrate}kbps (or lossless)`) - console.log() - - if (dryRun) { - console.log('Would search and download:') - for (const track of toDownload.slice(0, 5)) { - console.log(` ${track.title}`) - } - if (toDownload.length > 5) { - console.log(` [...${toDownload.length - 5} more]`) - } - return '' - } - - if (toDownload.length === 0) { - console.log('Nothing to download.') - return '' - } - - // Download tracks - const results = await downloadSoulseekTracks(client, toDownload, folderPath, { - concurrency, - verbose, - minBitrate - }) - - // Summary - console.log() - console.log('Summary:') - console.log(` Total: ${tracks.length}`) - console.log(` Downloaded: ${results.complete.length}`) - console.log(` Already exists: ${existing}`) - console.log(` No match found: ${results.no_match.length}`) - console.log(` Failed: ${results.failed.length}`) - - if (results.failed.length > 0) { - await writeFailures(results.failed, folderPath, {verbose}) - } - - if (results.no_match.length > 0) { - console.log() - console.log( - `⚠ ${results.no_match.length} tracks had no matches on Soulseek` - ) - if (verbose) { - for (const track of results.no_match.slice(0, 5)) { - console.log(` - ${track.title}`) - } - if (results.no_match.length > 5) { - console.log(` [...${results.no_match.length - 5} more]`) - } - } - } - - if (results.failed.length > 0) { - console.log() - console.log(`⚠ ${results.failed.length} tracks failed to download`) - console.log(` See: ${folderPath}/failures.jsonl`) - if (verbose) { - for (const {track, error} of results.failed.slice(0, 5)) { - console.log(` - ${track.title}: ${error}`) - } - } - } - - return '' -} diff --git a/cli/lib/config.js b/cli/lib/config.js index 4cafe31..5346ee4 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -17,17 +17,21 @@ const defaults = { } } -/** Load config from disk, return defaults if missing */ +/** Load config from disk, deep-merged with defaults */ export async function load() { try { const data = await readFile(configPath, 'utf-8') - return {...defaults, ...JSON.parse(data)} + const userConfig = JSON.parse(data) + // Deep merge so nested defaults (like soulseek.host) are preserved + return { + ...defaults, + ...userConfig, + soulseek: {...defaults.soulseek, ...userConfig.soulseek} + } } catch (error) { - // File doesn't exist yet - return defaults if (error.code === 'ENOENT') { return defaults } - // File exists but we can't read/parse it - that's a real error throw new Error( `Failed to load config from ${configPath}: ${error.message}` ) diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index e9f0788..409fd15 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -12,19 +12,16 @@ import {existsSync} from 'node:fs' import {copyFile, mkdir, unlink} from 'node:fs/promises' import {extname, join} from 'node:path' import getArtistTitle from 'get-artist-title' +import {load as loadConfig} from './config.js' +import {readFailedTrackIds, writeFailures} from './download.js' import {toFilename} from './filenames.js' // ===== CONSTANTS ===== -const DEFAULT_HOST = 'localhost' -const DEFAULT_PORT = 5030 -const DEFAULT_USERNAME = 'slskd' -const DEFAULT_PASSWORD = 'slskd' - // DJ-compatible formats (CDJ/Rekordbox/Traktor/Serato compatible) const LOSSLESS_FORMATS = ['flac', 'wav'] const LOSSY_FORMATS = ['mp3', 'ogg'] -const ALLOWED_FORMATS = [...LOSSLESS_FORMATS, ...LOSSY_FORMATS] +export const ALLOWED_FORMATS = [...LOSSLESS_FORMATS, ...LOSSY_FORMATS] const MIN_BITRATE = 320 // Quality ranking: higher is better (DJ-compatible only) @@ -91,16 +88,12 @@ const buildSearchQuery = (title) => { * Create slskd API client * Returns an object with methods to search and download from Soulseek */ -export function createClient(config = {}) { - const host = config.host || DEFAULT_HOST - const port = config.port || DEFAULT_PORT +export function createClient(config) { + const {host, port, username, password} = config const baseUrl = `http://${host}:${port}/api/v0` let token = null async function authenticate() { - const username = config.username || DEFAULT_USERNAME - const password = config.password || DEFAULT_PASSWORD - const response = await fetch(`${baseUrl}/session`, { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -463,3 +456,161 @@ export async function downloadTracks(client, tracks, folderPath, options = {}) { return results } + +// ===== CHANNEL DOWNLOAD (matches lib/download.js API) ===== + +/** + * Download a channel's tracks from Soulseek + * Matches the API of downloadChannel in lib/download.js for consistency + */ +export async function downloadChannel(tracks, folderPath, options = {}) { + const { + dryRun = false, + verbose = false, + force = false, + retryFailed = false, + concurrency = 2, + host, + port, + minBitrate = 320, + downloadsDir + } = options + + // Load config (already has defaults from config.js) + const config = await loadConfig() + const slskdDownloadsDir = + downloadsDir ?? + (config.downloadsDir ? join(config.downloadsDir, 'slskd') : null) + const slskdConfig = { + ...config.soulseek, + host: host ?? config.soulseek.host, + port: port ?? config.soulseek.port, + downloadsDir: slskdDownloadsDir + } + + // Create client and verify connection + const client = createClient(slskdConfig) + + if (!dryRun) { + console.log( + `Connecting to slskd at ${slskdConfig.host}:${slskdConfig.port}...` + ) + try { + await client.checkConnection() + console.log('Connected to slskd') + console.log() + } catch (error) { + console.error(`Failed to connect to slskd: ${error.message}`) + console.error() + console.error('Make sure slskd is running:') + console.error( + ' docker run -d --network host -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass slskd/slskd' + ) + console.error() + console.error('Or configure in ~/.config/radio4000/config.json') + throw error + } + } + + // Filter tracks that already exist + const soulseekDir = join(folderPath, 'soulseek') + const failedIds = retryFailed ? new Set() : readFailedTrackIds(folderPath) + + const hasExistingFile = (track) => { + const baseFilename = toFilename(track, {source: 'soulseek'}) + return ALLOWED_FORMATS.some((ext) => + existsSync(join(soulseekDir, `${baseFilename}.${ext}`)) + ) + } + + const toDownload = force + ? tracks + : tracks.filter((t) => !failedIds.has(t.id) && !hasExistingFile(t)) + + const existing = tracks.filter(hasExistingFile).length + const previouslyFailed = tracks.filter((t) => failedIds.has(t.id)).length + + console.log(`Total tracks: ${tracks.length}`) + console.log(` Already exists: ${existing}`) + if (previouslyFailed > 0) { + console.log(` Previously failed: ${previouslyFailed}`) + } + console.log(` To download: ${toDownload.length}`) + console.log(` Concurrency: ${concurrency}`) + console.log(` Min bitrate: ${minBitrate}kbps (or lossless)`) + console.log() + + if (dryRun) { + console.log('Would search and download:') + for (const track of toDownload.slice(0, 5)) { + console.log(` ${track.title}`) + } + if (toDownload.length > 5) { + console.log(` [...${toDownload.length - 5} more]`) + } + return { + total: tracks.length, + downloaded: 0, + existing, + noMatch: 0, + failed: 0, + failures: [] + } + } + + if (toDownload.length === 0) { + console.log('Nothing to download.') + return { + total: tracks.length, + downloaded: 0, + existing, + noMatch: 0, + failed: 0, + failures: [] + } + } + + // Download tracks + const results = await downloadTracks(client, toDownload, folderPath, { + concurrency, + verbose, + minBitrate + }) + + // Write failures + if (results.failed.length > 0) { + await writeFailures(results.failed, folderPath, {verbose}) + } + + // Summary + console.log() + console.log('Summary:') + console.log(` Total: ${tracks.length}`) + console.log(` Downloaded: ${results.complete.length}`) + console.log(` Already exists: ${existing}`) + console.log(` No match found: ${results.no_match.length}`) + console.log(` Failed: ${results.failed.length}`) + + if (results.no_match.length > 0) { + console.log() + console.log( + `⚠ ${results.no_match.length} tracks had no matches on Soulseek` + ) + } + + if (results.failed.length > 0) { + console.log() + console.log(`⚠ ${results.failed.length} tracks failed to download`) + console.log(` See: ${folderPath}/failures.jsonl`) + } + + // Return unified result format + return { + total: tracks.length, + downloaded: results.complete.length, + existing, + noMatch: results.no_match.length, + failed: results.failed.length, + failures: results.failed + } +} diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js index 49c8e20..e33e32b 100644 --- a/cli/lib/soulseek.test.js +++ b/cli/lib/soulseek.test.js @@ -70,7 +70,7 @@ test('search returns ranked results filtered by quality', async () => { globalThis.fetch = mockFetch(mockResponses) - const client = createClient() + const client = createClient({host: 'localhost', port: 5030, username: 'slskd', password: 'slskd'}) const results = await client.search('Artist Song', { timeout: 1000, minBitrate: 320 @@ -135,7 +135,7 @@ test('downloadTrack returns no_match when no results', async () => { {data: []} // No results ]) - const client = createClient() + const client = createClient({host: 'localhost', port: 5030, username: 'slskd', password: 'slskd'}) const track = {id: 'track-1', title: 'Unknown Artist - Rare Song'} const result = await downloadTrack(client, track, '/tmp/test', {}) @@ -180,7 +180,7 @@ test('quality scoring prefers lossless over high bitrate', () => { globalThis.fetch = mockFetch(mockResponses) - const client = createClient() + const client = createClient({host: 'localhost', port: 5030, username: 'slskd', password: 'slskd'}) return client.search('test', {timeout: 100}).then((results) => { // FLAC should be first despite being from user2 From 0830782de8b707a126f3eb24dac278990f53ac9b Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Thu, 15 Jan 2026 13:18:08 +0100 Subject: [PATCH 05/12] improve soulseek load config --- cli/commands/download.js | 14 +++++++++++--- cli/lib/soulseek.js | 22 ++++++++-------------- cli/lib/soulseek.test.js | 21 ++++++++++++++++++--- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index 6abbc36..97a165d 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -118,16 +118,24 @@ export default { // Download via source if (source === 'soulseek') { + // Build slskdConfig by merging CLI options with config.soulseek + const slskdDownloadsDir = + values['slskd-downloads-dir'] ?? + (config.downloadsDir ? join(config.downloadsDir, 'slskd') : null) + const slskdConfig = { + ...config.soulseek, + host: values['slskd-host'] ?? config.soulseek.host, + port: values['slskd-port'] ?? config.soulseek.port, + downloadsDir: slskdDownloadsDir + } await downloadSoulseek(tracks, folderPath, { dryRun, verbose, force: values.force, retryFailed: values['retry-failed'], concurrency: Math.min(values.concurrency, 3), - host: values['slskd-host'], - port: values['slskd-port'], minBitrate: values['min-bitrate'], - downloadsDir: values['slskd-downloads-dir'] + slskdConfig }) return '' } diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 409fd15..926eccf 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -12,7 +12,6 @@ import {existsSync} from 'node:fs' import {copyFile, mkdir, unlink} from 'node:fs/promises' import {extname, join} from 'node:path' import getArtistTitle from 'get-artist-title' -import {load as loadConfig} from './config.js' import {readFailedTrackIds, writeFailures} from './download.js' import {toFilename} from './filenames.js' @@ -462,6 +461,11 @@ export async function downloadTracks(client, tracks, folderPath, options = {}) { /** * Download a channel's tracks from Soulseek * Matches the API of downloadChannel in lib/download.js for consistency + * + * @param {Array} tracks - Tracks to download + * @param {string} folderPath - Output folder path + * @param {Object} options - Download options + * @param {Object} options.slskdConfig - slskd client config (host, port, username, password, downloadsDir) */ export async function downloadChannel(tracks, folderPath, options = {}) { const { @@ -470,22 +474,12 @@ export async function downloadChannel(tracks, folderPath, options = {}) { force = false, retryFailed = false, concurrency = 2, - host, - port, minBitrate = 320, - downloadsDir + slskdConfig } = options - // Load config (already has defaults from config.js) - const config = await loadConfig() - const slskdDownloadsDir = - downloadsDir ?? - (config.downloadsDir ? join(config.downloadsDir, 'slskd') : null) - const slskdConfig = { - ...config.soulseek, - host: host ?? config.soulseek.host, - port: port ?? config.soulseek.port, - downloadsDir: slskdDownloadsDir + if (!slskdConfig) { + throw new Error('slskdConfig is required') } // Create client and verify connection diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js index e33e32b..be53e72 100644 --- a/cli/lib/soulseek.test.js +++ b/cli/lib/soulseek.test.js @@ -70,7 +70,12 @@ test('search returns ranked results filtered by quality', async () => { globalThis.fetch = mockFetch(mockResponses) - const client = createClient({host: 'localhost', port: 5030, username: 'slskd', password: 'slskd'}) + const client = createClient({ + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd' + }) const results = await client.search('Artist Song', { timeout: 1000, minBitrate: 320 @@ -135,7 +140,12 @@ test('downloadTrack returns no_match when no results', async () => { {data: []} // No results ]) - const client = createClient({host: 'localhost', port: 5030, username: 'slskd', password: 'slskd'}) + const client = createClient({ + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd' + }) const track = {id: 'track-1', title: 'Unknown Artist - Rare Song'} const result = await downloadTrack(client, track, '/tmp/test', {}) @@ -180,7 +190,12 @@ test('quality scoring prefers lossless over high bitrate', () => { globalThis.fetch = mockFetch(mockResponses) - const client = createClient({host: 'localhost', port: 5030, username: 'slskd', password: 'slskd'}) + const client = createClient({ + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd' + }) return client.search('test', {timeout: 100}).then((results) => { // FLAC should be first despite being from user2 From 9bb374f78eee31c9a89eb045d5a1ce7428e04c7b Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Thu, 15 Jan 2026 14:16:49 +0100 Subject: [PATCH 06/12] change arg to `r4 download --soulseek` --- cli/commands/download.js | 33 ++++++++++++++------------------- cli/lib/soulseek.js | 9 +++++++-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index 97a165d..2d9e641 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -19,10 +19,11 @@ export default { type: 'string', description: 'Output folder path (defaults to config.downloadsDir/)' }, - source: { - type: 'string', - default: 'youtube', - description: 'Download source: youtube or soulseek' + soulseek: { + type: 'boolean', + default: false, + description: + 'Download from Soulseek instead of track URLs (requires slskd)' }, limit: { type: 'number', @@ -56,8 +57,7 @@ export default { concurrency: { type: 'number', default: 3, - description: - 'Number of concurrent downloads (youtube: 1-10, soulseek: 1-3)' + description: 'Number of concurrent downloads (max 3 for soulseek)' }, // Soulseek-specific options 'slskd-host': {type: 'string', description: 'slskd host'}, @@ -68,7 +68,8 @@ export default { }, 'slskd-downloads-dir': { type: 'string', - description: 'slskd downloads directory (for Docker)' + description: + 'Host path where slskd saves downloads (default: /tmp/radio4000/slskd)' } }, @@ -78,11 +79,6 @@ export default { const slug = positionals[0] if (!slug) throw new Error('Missing channel slug') - const source = values.source - if (source !== 'youtube' && source !== 'soulseek') { - throw new Error(`Invalid source: ${source}. Use 'youtube' or 'soulseek'`) - } - // Resolve output path from config const config = await loadConfig() const baseDir = values.output || config.downloadsDir || '.' @@ -97,7 +93,7 @@ export default { const tracks = await listTracks({channelSlugs: [slug], limit: values.limit}) console.log(`${channel.name} (@${channel.slug})`) - console.log(`Source: ${source}`) + if (values.soulseek) console.log('Source: Soulseek') if (dryRun) console.log(folderPath) console.log() @@ -117,11 +113,10 @@ export default { } // Download via source - if (source === 'soulseek') { + if (values.soulseek) { // Build slskdConfig by merging CLI options with config.soulseek const slskdDownloadsDir = - values['slskd-downloads-dir'] ?? - (config.downloadsDir ? join(config.downloadsDir, 'slskd') : null) + values['slskd-downloads-dir'] ?? '/tmp/radio4000/slskd' const slskdConfig = { ...config.soulseek, host: values['slskd-host'] ?? config.soulseek.host, @@ -173,13 +168,13 @@ export default { }, examples: [ - '# YouTube (default)', 'r4 download ko002', 'r4 download ko002 --limit 10 --dry-run', '', '# Soulseek (requires slskd)', - 'r4 download ko002 --source soulseek', + '# docker run -d --network host -v /tmp/radio4000/slskd:/app/downloads slskd/slskd', + 'r4 download ko002 --soulseek', '', - '# Output: ko002/tracks/ (youtube), ko002/soulseek/ (soulseek)' + '# Output: ko002/tracks/ (yt-dlp), ko002/soulseek/ (soulseek)' ] } diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 926eccf..220a89f 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -498,10 +498,15 @@ export async function downloadChannel(tracks, folderPath, options = {}) { console.error() console.error('Make sure slskd is running:') console.error( - ' docker run -d --network host -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass slskd/slskd' + ' docker run -d --network host -v /tmp/radio4000/slskd:/app/downloads \\' + ) + console.error( + ' -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass slskd/slskd' ) console.error() - console.error('Or configure in ~/.config/radio4000/config.json') + console.error( + 'Configure slskd credentials in ~/.config/radio4000/config.json' + ) throw error } } From ce07a8c88c787c98f882e0d138d8f1f981543786 Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Thu, 15 Jan 2026 14:26:05 +0100 Subject: [PATCH 07/12] improve soulseek dl fn names and cleantitle for file name --- cli/commands/download.js | 12 ++++++------ cli/lib/filenames.js | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index 2d9e641..06d35b0 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -3,12 +3,12 @@ import {join, resolve} from 'node:path' import {load as loadConfig} from '../lib/config.js' import {getChannel, listTracks} from '../lib/data.js' import { - downloadChannel as downloadYouTube, + downloadChannel, writeChannelAbout, writeChannelImageUrl, writeTracksPlaylist } from '../lib/download.js' -import {downloadChannel as downloadSoulseek} from '../lib/soulseek.js' +import {downloadChannel as downloadChannelSoulseek} from '../lib/soulseek.js' import {parse} from '../utils.js' export default { @@ -69,7 +69,7 @@ export default { 'slskd-downloads-dir': { type: 'string', description: - 'Host path where slskd saves downloads (default: /tmp/radio4000/slskd)' + 'Temp folder where slskd saves files before moving to channel (default: /tmp/radio4000/slskd)' } }, @@ -123,7 +123,7 @@ export default { port: values['slskd-port'] ?? config.soulseek.port, downloadsDir: slskdDownloadsDir } - await downloadSoulseek(tracks, folderPath, { + await downloadChannelSoulseek(tracks, folderPath, { dryRun, verbose, force: values.force, @@ -135,8 +135,8 @@ export default { return '' } - // YouTube via yt-dlp - const result = await downloadYouTube(tracks, folderPath, { + // Default: yt-dlp (supports YouTube, SoundCloud, Bandcamp, etc.) + const result = await downloadChannel(tracks, folderPath, { force: values.force, retryFailed: values['retry-failed'], dryRun, diff --git a/cli/lib/filenames.js b/cli/lib/filenames.js index b1a7a7a..4847aaf 100644 --- a/cli/lib/filenames.js +++ b/cli/lib/filenames.js @@ -22,26 +22,26 @@ export function toFilename(track, options = {}) { throw new Error(`Invalid track title: ${JSON.stringify(track.title)}`) } - // Sanitize title first - const cleanTitle = filenamify(track.title, { + // Remove characters not allowed in filenames + const safeTitle = filenamify(track.title, { maxLength: 180 // Leave room for ID suffix }) // Soulseek: use r4 track ID for uniqueness if (source === 'soulseek') { if (track.id) { - return `${cleanTitle} [r4-${track.id.slice(0, 8)}]` + return `${safeTitle} [r4-${track.id.slice(0, 8)}]` } - return cleanTitle + return safeTitle } // YouTube: add YouTube ID suffix if available (for uniqueness) const ytId = extractYouTubeId(track.url) if (ytId) { - return `${cleanTitle} [${ytId}]` + return `${safeTitle} [${ytId}]` } - return cleanTitle + return safeTitle } /** From 64bc4807a06246284af3bb1c5a988ae0410f9636 Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Thu, 15 Jan 2026 15:02:45 +0100 Subject: [PATCH 08/12] improve soulseek results matching to title --- cli/lib/soulseek.js | 91 +++++++++++++++++++++++++++++++++++----- cli/lib/soulseek.test.js | 12 +++--- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 220a89f..9f4f8ea 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -69,16 +69,77 @@ const calculateScore = (file) => { return score } +const stripBracketed = (text) => text.replace(/\s*[[(][^\])]*[\])]/g, '').trim() +const normalizeText = (text) => + stripBracketed(text) + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + +const parseArtistTitle = (text) => { + const parsed = getArtistTitle(text) + if (!parsed) return null + const [artist, title] = parsed + if (!artist && !title) return null + return {artist, title} +} + const buildSearchQuery = (title) => { // get-artist-title handles basic cleanup const parsed = getArtistTitle(title) - let query = parsed ? `${parsed[0]} ${parsed[1]}` : title + const query = parsed ? `${parsed[0]} ${parsed[1]}` : title // Strip parenthetical/bracketed content for search - Soulseek search // works better with simpler queries, and results include all versions anyway - query = query.replace(/\s*[[(][^\])]*[\])]/g, '') + return stripBracketed(query) +} + +const buildSearchQueries = (title) => { + const queries = [] + const addQuery = (query) => { + const trimmed = query.trim() + if (!trimmed) return + if (!queries.includes(trimmed)) queries.push(trimmed) + } + + const parsedQuery = buildSearchQuery(title) + addQuery(parsedQuery) + + const rawQuery = stripBracketed(title) + addQuery(rawQuery) + + const compactHyphenQuery = rawQuery.replace(/\s*-\s*/g, '-') + addQuery(compactHyphenQuery) + + return queries +} + +const filterResultsByTrack = (files, trackTitle) => { + const trackParsed = parseArtistTitle(trackTitle) + const trackComposite = trackParsed + ? normalizeText(`${trackParsed.artist} ${trackParsed.title}`) + : normalizeText(trackTitle) - return query.trim() + if (!trackComposite) return files + + const filtered = files.filter((file) => { + const fileName = file.filename.split(/[\\/]/).pop() || '' + const ext = extname(fileName) + const stem = ext ? fileName.slice(0, -ext.length) : fileName + const parsed = parseArtistTitle(stem) + if (!parsed) return false + + const fileComposite = normalizeText(`${parsed.artist} ${parsed.title}`) + if (!fileComposite) return false + + return ( + fileComposite === trackComposite || + fileComposite.includes(trackComposite) || + trackComposite.includes(fileComposite) + ) + }) + + return filtered.length > 0 ? filtered : files } // ===== CLIENT ===== @@ -322,20 +383,28 @@ export function createClient(config) { */ export async function downloadTrack(client, track, outputDir, options = {}) { const {verbose = false, minBitrate = MIN_BITRATE} = options - const query = buildSearchQuery(track.title) + const queries = buildSearchQueries(track.title) + let results = [] + let query = null - if (verbose) console.log(` Searching: "${query}"`) + for (const candidate of queries) { + query = candidate + if (verbose) console.log(` Searching: "${query}"`) - const results = await client.search(query, { - timeout: options.searchTimeout || 15000, - minBitrate - }) + results = await client.search(query, { + timeout: options.searchTimeout || 15000, + minBitrate + }) + + if (results.length > 0) break + } if (results.length === 0) { - return {status: 'no_match', track, query} + return {status: 'no_match', track, query: queries[0] || track.title} } - const best = results[0] + const filteredResults = filterResultsByTrack(results, track.title) + const best = filteredResults[0] if (verbose) { const quality = best.isLossless diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js index be53e72..5c5fdb5 100644 --- a/cli/lib/soulseek.test.js +++ b/cli/lib/soulseek.test.js @@ -96,10 +96,10 @@ test('search returns ranked results filtered by quality', async () => { }) test('buildSearchQuery uses get-artist-title to parse and clean', async () => { - let capturedQuery = null + const capturedQueries = [] const client = { search: mock(async (query) => { - capturedQuery = query + capturedQueries.push(query) return [] }) } @@ -111,14 +111,14 @@ test('buildSearchQuery uses get-artist-title to parse and clean', async () => { } await downloadTrack(client, track, '/tmp/test', {}) - expect(capturedQuery).toBe('Artist Song') + expect(capturedQueries[0]).toBe('Artist Song') }) test('buildSearchQuery strips all parenthetical content for better Soulseek search', async () => { - let capturedQuery = null + const capturedQueries = [] const client = { search: mock(async (query) => { - capturedQuery = query + capturedQueries.push(query) return [] }) } @@ -127,7 +127,7 @@ test('buildSearchQuery strips all parenthetical content for better Soulseek sear await downloadTrack(client, track, '/tmp/test', {}) // Parenthetical content stripped for search - Soulseek works better with simpler queries - expect(capturedQuery).toBe('Artist Song') + expect(capturedQueries[0]).toBe('Artist Song') }) test('downloadTrack returns no_match when no results', async () => { From 41110810c6cab7c99f10e84435b4f08ec03088a6 Mon Sep 17 00:00:00 2001 From: oskarrough Date: Thu, 15 Jan 2026 21:21:53 +0100 Subject: [PATCH 09/12] Move default soulseek download dir config up --- cli/commands/download.js | 7 +++---- cli/lib/config.js | 3 ++- cli/lib/soulseek.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/commands/download.js b/cli/commands/download.js index 06d35b0..1039a04 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -69,7 +69,7 @@ export default { 'slskd-downloads-dir': { type: 'string', description: - 'Temp folder where slskd saves files before moving to channel (default: /tmp/radio4000/slskd)' + 'Folder where slskd saves files (default: config.soulseek.downloadsDir)' } }, @@ -115,13 +115,12 @@ export default { // Download via source if (values.soulseek) { // Build slskdConfig by merging CLI options with config.soulseek - const slskdDownloadsDir = - values['slskd-downloads-dir'] ?? '/tmp/radio4000/slskd' const slskdConfig = { ...config.soulseek, host: values['slskd-host'] ?? config.soulseek.host, port: values['slskd-port'] ?? config.soulseek.port, - downloadsDir: slskdDownloadsDir + downloadsDir: + values['slskd-downloads-dir'] ?? config.soulseek.downloadsDir } await downloadChannelSoulseek(tracks, folderPath, { dryRun, diff --git a/cli/lib/config.js b/cli/lib/config.js index 5346ee4..29f32b5 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -13,7 +13,8 @@ const defaults = { host: 'localhost', port: 5030, username: 'slskd', - password: 'slskd' + password: 'slskd', + downloadsDir: '/tmp/radio4000/slskd' } } diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 9f4f8ea..7a89cbd 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -567,7 +567,7 @@ export async function downloadChannel(tracks, folderPath, options = {}) { console.error() console.error('Make sure slskd is running:') console.error( - ' docker run -d --network host -v /tmp/radio4000/slskd:/app/downloads \\' + ` docker run -d --network host -v ${slskdConfig.downloadsDir}:/app/downloads \\` ) console.error( ' -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass slskd/slskd' From 1a1ba342182970672a1744b768efcfa823590ae8 Mon Sep 17 00:00:00 2001 From: oskarrough Date: Thu, 15 Jan 2026 21:22:01 +0100 Subject: [PATCH 10/12] Set biome version --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 6b9e58a..6c0c008 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": true, "clientKind": "git", From c5ba5d08a4af16f8ce431dc7fb633b4bdc9477cd Mon Sep 17 00:00:00 2001 From: oskarrough Date: Thu, 15 Jan 2026 21:33:58 +0100 Subject: [PATCH 11/12] Consistent return types for dl methods --- cli/lib/soulseek.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/cli/lib/soulseek.js b/cli/lib/soulseek.js index 7a89cbd..22de338 100644 --- a/cli/lib/soulseek.js +++ b/cli/lib/soulseek.js @@ -451,7 +451,7 @@ export async function downloadTrack(client, track, outputDir, options = {}) { export async function downloadTracks(client, tracks, folderPath, options = {}) { const {verbose = false, dryRun = false, concurrency = 1} = options - const results = {complete: [], no_match: [], failed: [], skipped: []} + const results = {complete: [], unavailable: [], failed: [], skipped: []} const outputDir = join(folderPath, 'soulseek') if (!dryRun) { @@ -504,7 +504,7 @@ export async function downloadTracks(client, tracks, folderPath, options = {}) { if (result.status === 'no_match') { console.log(`${progress} No match: ${track.title}`) - results.no_match.push(track) + results.unavailable.push(track) } else { const quality = result.quality.isLossless ? result.quality.format.toUpperCase() @@ -620,7 +620,8 @@ export async function downloadChannel(tracks, folderPath, options = {}) { total: tracks.length, downloaded: 0, existing, - noMatch: 0, + previouslyFailed, + unavailable: 0, failed: 0, failures: [] } @@ -632,7 +633,8 @@ export async function downloadChannel(tracks, folderPath, options = {}) { total: tracks.length, downloaded: 0, existing, - noMatch: 0, + previouslyFailed, + unavailable: 0, failed: 0, failures: [] } @@ -656,13 +658,13 @@ export async function downloadChannel(tracks, folderPath, options = {}) { console.log(` Total: ${tracks.length}`) console.log(` Downloaded: ${results.complete.length}`) console.log(` Already exists: ${existing}`) - console.log(` No match found: ${results.no_match.length}`) + console.log(` No match found: ${results.unavailable.length}`) console.log(` Failed: ${results.failed.length}`) - if (results.no_match.length > 0) { + if (results.unavailable.length > 0) { console.log() console.log( - `⚠ ${results.no_match.length} tracks had no matches on Soulseek` + `⚠ ${results.unavailable.length} tracks had no matches on Soulseek` ) } @@ -677,7 +679,8 @@ export async function downloadChannel(tracks, folderPath, options = {}) { total: tracks.length, downloaded: results.complete.length, existing, - noMatch: results.no_match.length, + previouslyFailed, + unavailable: results.unavailable.length, failed: results.failed.length, failures: results.failed } From e35d84d242f8747873ba93173daf3eb8abd2f461 Mon Sep 17 00:00:00 2001 From: oskarrough Date: Sat, 17 Jan 2026 23:39:32 +0100 Subject: [PATCH 12/12] Add slskd example script --- .env.example | 3 + .gitignore | 2 + cli/lib/collect-search-data.js | 152 +++++++++++++++++++++++++++++++++ scripts/slskd-start.sh | 37 ++++++++ 4 files changed, 194 insertions(+) create mode 100644 .env.example create mode 100644 cli/lib/collect-search-data.js create mode 100755 scripts/slskd-start.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5590eb4 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Soulseek network credentials (not your slskd credential, but used by slskd) +SLSK_USERNAME=your_soulseek_username +SLSK_PASSWORD=your_soulseek_password diff --git a/.gitignore b/.gitignore index 967b694..bd8fc6f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ downloads node_modules data/channels_v1.json data/tracks_v1.json +.env +cli/lib/test-data/ diff --git a/cli/lib/collect-search-data.js b/cli/lib/collect-search-data.js new file mode 100644 index 0000000..3f887d3 --- /dev/null +++ b/cli/lib/collect-search-data.js @@ -0,0 +1,152 @@ +#!/usr/bin/env bun +/** + * Collect raw Soulseek search results for analysis + * Usage: bun cli/lib/collect-search-data.js [tracks.json] [limit] + * Example: bun cli/lib/collect-search-data.js cli/lib/test-data/ko002-tracks.json 5 + */ + +import {appendFile, readFile, writeFile} from 'node:fs/promises' +import {load} from './config.js' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function createClient(config) { + const {host, port, username, password} = config + const baseUrl = `http://${host}:${port}/api/v0` + let token = null + + async function authenticate() { + const response = await fetch(`${baseUrl}/session`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, password}) + }) + if (!response.ok) { + throw new Error(`Auth failed: ${response.status}`) + } + const data = await response.json() + token = data.token + } + + async function request(path, options = {}) { + if (!token) await authenticate() + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + if (response.status === 401) { + await authenticate() + return request(path, options) + } + return response + } + + return {request} +} + +async function searchRaw(client, query, timeout = 15000) { + console.log(`Searching: "${query}"`) + + const searchResponse = await client.request('/searches', { + method: 'POST', + body: JSON.stringify({ + searchText: query, + searchTimeout: timeout, + filterResponses: true, + minimumResponseFileCount: 1 + }) + }) + + if (!searchResponse.ok) { + console.error(` Search failed: ${searchResponse.status}`) + return null + } + + const {id: searchId} = await searchResponse.json() + + // Poll for completion + const start = Date.now() + while (Date.now() - start < timeout + 5000) { + const stateRes = await client.request(`/searches/${searchId}`) + if (stateRes.ok) { + const state = await stateRes.json() + if (state.state === 'Completed' || state.isComplete) break + } + await sleep(1000) + } + + // Get RAW responses (no filtering) + const responsesRes = await client.request(`/searches/${searchId}/responses`) + if (!responsesRes.ok) { + console.error(` Failed to get responses`) + return null + } + + const responses = await responsesRes.json() + + // Count files + let fileCount = 0 + for (const r of responses) { + fileCount += r.files?.length || 0 + } + console.log(` Found ${responses.length} users, ${fileCount} files`) + + return { + query, + timestamp: new Date().toISOString(), + responses + } +} + +async function main() { + const args = process.argv.slice(2) + const tracksFile = args[0] || 'cli/lib/test-data/ko002-tracks.json' + const limit = parseInt(args[1]) || 5 + + // Load tracks + const tracksJson = await readFile(tracksFile, 'utf-8') + const tracks = JSON.parse(tracksJson).slice(0, limit) + + console.log(`Loaded ${tracks.length} tracks from ${tracksFile}\n`) + + const config = await load() + const slskdConfig = config.soulseek + + console.log( + `Connecting to slskd at ${slskdConfig.host}:${slskdConfig.port}...` + ) + + const client = await createClient(slskdConfig) + + // Test connection + const testRes = await client.request('/application') + if (!testRes.ok) { + console.error('Failed to connect to slskd. Is it running?') + process.exit(1) + } + console.log('Connected!\n') + + const outputFile = 'cli/lib/test-data/search-results.jsonl' + + // Clear/create file + await writeFile(outputFile, '') + + for (const track of tracks) { + const result = await searchRaw(client, track.title) + if (result) { + // Include original track info for matching analysis + result.track = {id: track.id, title: track.title} + await appendFile(outputFile, JSON.stringify(result) + '\n') + } + // Small delay between searches + await sleep(2000) + } + + console.log(`\nDone! Data saved to ${outputFile}`) +} + +main().catch(console.error) diff --git a/scripts/slskd-start.sh b/scripts/slskd-start.sh new file mode 100755 index 0000000..c3ce70e --- /dev/null +++ b/scripts/slskd-start.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Start slskd container for Soulseek downloads +# Reads credentials from .env file + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/../.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing .env file. Create one with:" + echo " SLSK_USERNAME=your_soulseek_username" + echo " SLSK_PASSWORD=your_soulseek_password" + exit 1 +fi + +source "$ENV_FILE" + +if [ -z "$SLSK_USERNAME" ] || [ -z "$SLSK_PASSWORD" ]; then + echo "Missing SLSK_USERNAME or SLSK_PASSWORD in .env" + exit 1 +fi + +# Stop existing container if running +docker stop slskd 2>/dev/null +docker rm slskd 2>/dev/null + +# Start fresh +docker run -d --name slskd \ + -p 5030:5030 -p 5031:5031 \ + -v /tmp/radio4000/slskd:/app/downloads \ + -e SLSKD_SLSK_USERNAME="$SLSK_USERNAME" \ + -e SLSKD_SLSK_PASSWORD="$SLSK_PASSWORD" \ + slskd/slskd + +echo "slskd starting... web UI at http://localhost:5030 (login: slskd/slskd)" +echo "Waiting for startup..." +sleep 3 +docker logs slskd --tail 5