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
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ npm i -g r4
r4 help
```

Here's a quick overview:
> 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.

```bash
r4 channel list --limit 10
Expand All @@ -30,7 +32,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

> For the `r4 download` command to work, make sure [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) is installed.

Expand Down
104 changes: 73 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:
'Temp folder where slskd saves files before moving to channel (default: /tmp/radio4000/slskd)'
}
},

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,49 @@ 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 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
}
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 +145,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 +164,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)'
]
}
30 changes: 23 additions & 7 deletions cli/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,33 @@ 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'
}
}

/** 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}`
)
Expand All @@ -32,13 +45,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)
}
55 changes: 41 additions & 14 deletions cli/lib/filenames.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,68 @@
*/

import filenamify from 'filenamify'
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 media ID suffix if available (for uniqueness)
if (track.media_id) {
return `${cleanTitle} [${track.media_id}]`
// 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 `${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
}

return track.provider === 'soundcloud' ? 'mp3' : 'm4a'
// Soulseek: default to flac (actual extension set during download)
if (source === 'soulseek') {
return 'flac'
}

const provider = detectMediaProvider(track.url)
return provider === 'soundcloud' ? 'mp3' : 'm4a'
}
Loading