Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ downloads
node_modules
data/channels_v1.json
data/tracks_v1.json
.env
cli/lib/test-data/
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
103 changes: 72 additions & 31 deletions cli/commands/download.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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,
writeChannelAbout,
writeChannelImageUrl,
writeTracksPlaylist
} from '../lib/download.js'
import {downloadChannel as downloadChannelSoulseek} from '../lib/soulseek.js'
import {parse} from '../utils.js'

export default {
Expand All @@ -14,7 +17,13 @@ export default {
options: {
output: {
type: 'string',
description: 'Output folder path (defaults to ./<slug>)'
description: 'Output folder path (defaults to config.downloadsDir/<slug>)'
},
soulseek: {
type: 'boolean',
default: false,
description:
'Download from Soulseek instead of track URLs (requires slskd)'
},
limit: {
type: 'number',
Expand Down Expand Up @@ -48,19 +57,33 @@ 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)'
}
},

async run(argv) {
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']
Expand All @@ -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'],
Expand All @@ -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:')
Expand All @@ -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'
Comment on lines -129 to -136
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove these examples? seems nice to have them

'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)'
]
}
152 changes: 152 additions & 0 deletions cli/lib/collect-search-data.js
Original file line number Diff line number Diff line change
@@ -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)
Loading