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/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/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", diff --git a/cli/commands/download.js b/cli/commands/download.js index 277dbab..1039a04 100644 --- a/cli/commands/download.js +++ b/cli/commands/download.js @@ -1,4 +1,6 @@ -import {resolve} from 'node:path' +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 +8,7 @@ import { writeChannelImageUrl, writeTracksPlaylist } from '../lib/download.js' +import {downloadChannel as downloadChannelSoulseek} from '../lib/soulseek.js' import {parse} from '../utils.js' export default { @@ -14,7 +17,13 @@ export default { options: { output: { type: 'string', - description: 'Output folder path (defaults to ./)' + description: 'Output folder path (defaults to config.downloadsDir/)' + }, + soulseek: { + type: 'boolean', + default: false, + description: + 'Download from Soulseek instead of track URLs (requires slskd)' }, limit: { type: 'number', @@ -48,7 +57,19 @@ export default { concurrency: { type: 'number', default: 3, - description: 'Number of concurrent downloads' + description: 'Number of concurrent downloads (max 3 for soulseek)' + }, + // 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: + 'Folder where slskd saves files (default: config.soulseek.downloadsDir)' } }, @@ -56,11 +77,13 @@ 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') + + // Resolve output path from config + const config = await loadConfig() + const baseDir = values.output || config.downloadsDir || '.' + const folderPath = resolve(join(baseDir, slug)) - const folderPath = resolve(values.output || `./${slug}`) const dryRun = values['dry-run'] const verbose = values.verbose const noMetadata = values['no-metadata'] @@ -70,27 +93,48 @@ export default { const tracks = await listTracks({channelSlugs: [slug], limit: values.limit}) console.log(`${channel.name} (@${channel.slug})`) - if (dryRun) { - console.log(folderPath) - } + if (values.soulseek) console.log('Source: Soulseek') + if (dryRun) console.log(folderPath) console.log() - // Write channel context files (unless dry run) + // Write metadata files if (!dryRun) { - const {mkdir} = await import('node:fs/promises') await mkdir(folderPath, {recursive: true}) + 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() + } + } - 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() + // Download via source + if (values.soulseek) { + // Build slskdConfig by merging CLI options with config.soulseek + const slskdConfig = { + ...config.soulseek, + host: values['slskd-host'] ?? config.soulseek.host, + port: values['slskd-port'] ?? config.soulseek.port, + downloadsDir: + values['slskd-downloads-dir'] ?? config.soulseek.downloadsDir + } + await downloadChannelSoulseek(tracks, folderPath, { + dryRun, + verbose, + force: values.force, + retryFailed: values['retry-failed'], + concurrency: Math.min(values.concurrency, 3), + minBitrate: values['min-bitrate'], + slskdConfig + }) + return '' } - // Download + // Default: yt-dlp (supports YouTube, SoundCloud, Bandcamp, etc.) const result = await downloadChannel(tracks, folderPath, { force: values.force, retryFailed: values['retry-failed'], @@ -100,7 +144,6 @@ export default { concurrency: values.concurrency }) - // Only show summary and failures for actual downloads, not dry runs if (!dryRun) { console.log() console.log('Summary:') @@ -120,19 +163,17 @@ export default { } } - // Don't return data - all output already printed above return '' }, examples: [ 'r4 download ko002', - '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', - 'mpv ko002/tracks.m3u' + 'r4 download ko002 --limit 10 --dry-run', + '', + '# Soulseek (requires slskd)', + '# docker run -d --network host -v /tmp/radio4000/slskd:/app/downloads slskd/slskd', + 'r4 download ko002 --soulseek', + '', + '# Output: ko002/tracks/ (yt-dlp), ko002/soulseek/ (soulseek)' ] } 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/cli/lib/config.js b/cli/lib/config.js index b20fa9e..29f32b5 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -5,20 +5,34 @@ import {join} from 'node:path' const configPath = join(homedir(), '.config', 'radio4000', 'config.json') const defaults = { - auth: {session: null} + auth: {session: null}, + // Base directory for all downloads (channels saved as subfolders) + downloadsDir: null, + // slskd connection settings (optional, defaults work for local Docker) + soulseek: { + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd', + downloadsDir: '/tmp/radio4000/slskd' + } } -/** 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}` ) @@ -32,13 +46,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/filenames.js b/cli/lib/filenames.js index 4512cd9..4847aaf 100644 --- a/cli/lib/filenames.js +++ b/cli/lib/filenames.js @@ -8,40 +8,64 @@ 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)}`) } - // 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 }) - // Add YouTube ID suffix if available (for uniqueness) + // Soulseek: use r4 track ID for uniqueness + if (source === 'soulseek') { + if (track.id) { + return `${safeTitle} [r4-${track.id.slice(0, 8)}]` + } + 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 } /** - * 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..22de338 --- /dev/null +++ b/cli/lib/soulseek.js @@ -0,0 +1,687 @@ +/** + * 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 {copyFile, mkdir, unlink} from 'node:fs/promises' +import {extname, join} from 'node:path' +import getArtistTitle from 'get-artist-title' +import {readFailedTrackIds, writeFailures} from './download.js' +import {toFilename} from './filenames.js' + +// ===== CONSTANTS ===== + +// DJ-compatible formats (CDJ/Rekordbox/Traktor/Serato compatible) +const LOSSLESS_FORMATS = ['flac', 'wav'] +const LOSSY_FORMATS = ['mp3', 'ogg'] +export const ALLOWED_FORMATS = [...LOSSLESS_FORMATS, ...LOSSY_FORMATS] +const MIN_BITRATE = 320 + +// Quality ranking: higher is better (DJ-compatible only) +const FORMAT_SCORES = { + flac: 1000, + wav: 900, + mp3: 100, + ogg: 100 +} + +// ===== 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 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) + 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 + 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) + + 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 ===== + +/** + * Create slskd API client + * Returns an object with methods to search and download from Soulseek + */ +export 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) { + 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() + 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, + 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) + + // 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() + 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} + } + + const isFailed = + transfer.state?.includes('Errored') || + transfer.state?.includes('Rejected') || + transfer.state?.includes('Cancelled') + if (isFailed) { + throw new Error(`download failed: ${transfer.state}`) + } + } + + await sleep(2000) + } + + throw new Error('download timed out') + } + + 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( + 'failed to get slskd options - cannot determine downloads directory' + ) + } + 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} +} + +// ===== DOWNLOAD FUNCTIONS ===== + +/** + * Download a single track via Soulseek + */ +export async function downloadTrack(client, track, outputDir, options = {}) { + const {verbose = false, minBitrate = MIN_BITRATE} = options + const queries = buildSearchQueries(track.title) + let results = [] + let query = null + + for (const candidate of queries) { + query = candidate + if (verbose) console.log(` Searching: "${query}"`) + + 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: queries[0] || track.title} + } + + const filteredResults = filterResultsByTrack(results, track.title) + const best = filteredResults[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 + }) + + // 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 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 { + 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, concurrency = 1} = options + + const results = {complete: [], unavailable: [], failed: [], skipped: []} + const outputDir = join(folderPath, 'soulseek') + + if (!dryRun) { + await mkdir(outputDir, {recursive: true}) + } + + 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) + } + return results + } + + // 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 + } + } + + 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.unavailable.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}) + } + } + }) + + await Promise.all(workers) + + 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 + * + * @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 { + dryRun = false, + verbose = false, + force = false, + retryFailed = false, + concurrency = 2, + minBitrate = 320, + slskdConfig + } = options + + if (!slskdConfig) { + throw new Error('slskdConfig is required') + } + + // 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 -v ${slskdConfig.downloadsDir}:/app/downloads \\` + ) + console.error( + ' -e SLSKD_SLSK_USERNAME=user -e SLSKD_SLSK_PASSWORD=pass slskd/slskd' + ) + console.error() + console.error( + 'Configure slskd credentials 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, + previouslyFailed, + unavailable: 0, + failed: 0, + failures: [] + } + } + + if (toDownload.length === 0) { + console.log('Nothing to download.') + return { + total: tracks.length, + downloaded: 0, + existing, + previouslyFailed, + unavailable: 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.unavailable.length}`) + console.log(` Failed: ${results.failed.length}`) + + if (results.unavailable.length > 0) { + console.log() + console.log( + `⚠ ${results.unavailable.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, + previouslyFailed, + unavailable: results.unavailable.length, + failed: results.failed.length, + failures: results.failed + } +} diff --git a/cli/lib/soulseek.test.js b/cli/lib/soulseek.test.js new file mode 100644 index 0000000..5c5fdb5 --- /dev/null +++ b/cli/lib/soulseek.test.js @@ -0,0 +1,211 @@ +import {expect, mock, test} from 'bun:test' +import {createClient, downloadTrack} 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({ + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd' + }) + 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 uses get-artist-title to parse and clean', async () => { + const capturedQueries = [] + const client = { + search: mock(async (query) => { + capturedQueries.push(query) + return [] + }) + } + + // 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', {}) + + expect(capturedQueries[0]).toBe('Artist Song') +}) + +test('buildSearchQuery strips all parenthetical content for better Soulseek search', async () => { + const capturedQueries = [] + const client = { + search: mock(async (query) => { + capturedQueries.push(query) + return [] + }) + } + + const track = {id: 'track-2', title: 'Artist - Song (Dub Mix)'} + await downloadTrack(client, track, '/tmp/test', {}) + + // Parenthetical content stripped for search - Soulseek works better with simpler queries + expect(capturedQueries[0]).toBe('Artist Song') +}) + +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({ + 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', {}) + + 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({ + host: 'localhost', + port: 5030, + username: 'slskd', + password: 'slskd' + }) + + 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 + }) +}) + +// Note: Title cleaning is handled by get-artist-title library +// It strips (Official Video), [Remastered], etc. but keeps DJ-relevant markers like (Dub Mix) 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