-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
typescript refactor, add deno support
- Loading branch information
Showing
24 changed files
with
873 additions
and
2,089 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,57 @@ | ||
# SoundCloud Sync | ||
|
||
A library and CLI tool to sync your SoundCloud likes to local files. | ||
|
||
![](https://i.snipboard.io/ZfMQL5.jpg) | ||
|
||
## Typical Usage | ||
## Features | ||
|
||
- Download liked tracks from any SoundCloud profile | ||
- Automatic metadata tagging (title, artist, artwork) | ||
- Preserves like dates as file modification times | ||
- Supports incremental syncing (only downloads new likes) | ||
- Can be used as a library in other projects | ||
|
||
## Usage | ||
|
||
The CLI accepts the following arguments: | ||
```bash | ||
<username> # Required: SoundCloud username to fetch likes from | ||
[folder] # Optional: Output folder (default: ./music) | ||
--limit <number> # Optional: Maximum number of likes to fetch | ||
``` | ||
|
||
### Node.js | ||
|
||
```bash | ||
# Install dependencies | ||
yarn install | ||
|
||
# Basic usage | ||
yarn start realies | ||
|
||
# Custom folder | ||
yarn start realies ./my-music | ||
|
||
# Limit number of likes | ||
yarn start realies --limit 100 | ||
|
||
# With debug logs | ||
LOG_LEVEL=debug yarn start realies | ||
``` | ||
yarn start realies mylikesfolder | ||
|
||
### Deno | ||
|
||
```bash | ||
# Basic usage | ||
deno task start realies | ||
|
||
# Custom folder | ||
deno task start realies ./my-music | ||
|
||
# Limit number of likes | ||
deno task start realies --limit 100 | ||
|
||
# With debug logs | ||
LOG_LEVEL=debug deno task start realies | ||
``` | ||
If no likes folder is set, a `music` subfolder is created. Running the script subsequently adds missing files. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
#!/usr/bin/env node | ||
|
||
require('ts-node/register'); | ||
require('../src/cli.ts'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
{ | ||
"tasks": { | ||
"start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-run --allow-ffi src/cli.ts" | ||
}, | ||
"imports": { | ||
"ffmpeg-static": "npm:ffmpeg-static@^5.2.0", | ||
"utimes": "npm:utimes@^5.2.1" | ||
}, | ||
"unstable": ["sloppy-imports"], | ||
"compilerOptions": { | ||
"lib": ["deno.ns", "es2020"], | ||
"strict": true | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { soundCloudSync } from './src/index.ts'; | ||
export type { UserLikesResponse, Track } from './src/types.ts'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,35 +1,25 @@ | ||
{ | ||
"name": "soundcloud-sync", | ||
"version": "0.0.1", | ||
"main": "src/server.js", | ||
"version": "0.0.2", | ||
"license": "MIT", | ||
"main": "src/cli.ts", | ||
"scripts": { | ||
"start": "babel-node src/server.js", | ||
"dev_start": "nodemon --exec babel-node src/server.js" | ||
"start": "ts-node src/cli.ts" | ||
}, | ||
"devDependencies": { | ||
"babel-eslint": "^10.1.0", | ||
"eslint": "^8.57.1", | ||
"eslint": "^9.17.0", | ||
"eslint-config-airbnb-base": "^15.0.0", | ||
"eslint-config-prettier": "^9.1.0", | ||
"eslint-plugin-filenames": "^1.3.2", | ||
"eslint-plugin-import": "^2.31.0", | ||
"eslint-plugin-node": "^11.1.0", | ||
"eslint-plugin-prettier": "^5.2.1", | ||
"prettier": "^3.3.3" | ||
"prettier": "^3.4.2", | ||
"ts-node": "^10.9.1", | ||
"typescript": "^5.0.0" | ||
}, | ||
"dependencies": { | ||
"@babel/cli": "^7.25.9", | ||
"@babel/core": "^7.26.0", | ||
"@babel/node": "^7.26.0", | ||
"@babel/preset-env": "^7.26.0", | ||
"@babel/register": "^7.25.9", | ||
"ffmpeg-static": "^5.2.0", | ||
"moment": "^2.30.1", | ||
"nodemon": "^3.1.7", | ||
"pino": "^9.5.0", | ||
"pino-pretty": "^13.0.0", | ||
"sanitize-filename": "^1.6.3", | ||
"utimes": "^5.2.1" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
#!/usr/bin/env node | ||
import { soundCloudSync } from './index'; | ||
import { logger } from './helpers/logger'; | ||
|
||
(async () => { | ||
try { | ||
const [,, username, folder, ...args] = process.argv; | ||
if (!username) { | ||
logger.error('Usage error', { message: 'soundcloud-sync <username> [folder] [--limit <number>]' }); | ||
process.exit(1); | ||
} | ||
|
||
const limit = args.includes('--limit') ? Number(args[args.indexOf('--limit') + 1]) : undefined; | ||
|
||
await soundCloudSync({ username, folder, limit }); | ||
} catch (error) { | ||
logger.error('CLI error', { error: error instanceof Error ? error.message : String(error) }); | ||
process.exit(1); | ||
} | ||
})(); |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; | ||
|
||
function formatDate(date: Date): string { | ||
const year = date.getFullYear(); | ||
const month = (date.getMonth() + 1).toString().padStart(2, '0'); | ||
const day = date.getDate().toString().padStart(2, '0'); | ||
const hours = date.getHours().toString().padStart(2, '0'); | ||
const minutes = date.getMinutes().toString().padStart(2, '0'); | ||
const seconds = date.getSeconds().toString().padStart(2, '0'); | ||
const ms = date.getMilliseconds().toString().padStart(3, '0'); | ||
|
||
const offset = -date.getTimezoneOffset(); | ||
const offsetHours = Math.floor(Math.abs(offset) / 60).toString().padStart(2, '0'); | ||
const offsetMinutes = (Math.abs(offset) % 60).toString().padStart(2, '0'); | ||
const offsetSign = offset >= 0 ? '+' : '-'; | ||
|
||
return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms} ${offsetSign}${offsetHours}${offsetMinutes}]`; | ||
} | ||
|
||
function formatMessage(level: LogLevel, ...args: unknown[]): string { | ||
const timestamp = formatDate(new Date()); | ||
const parts = args.map(arg => { | ||
if (typeof arg === 'string') return arg; | ||
return JSON.stringify(arg, null, 2); | ||
}); | ||
return `${timestamp} ${level}: ${parts.join(' ')}`; | ||
} | ||
|
||
class Logger { | ||
private level: LogLevel = 'INFO'; | ||
|
||
trace(...args: unknown[]): void { | ||
if (this.isEnabled('TRACE')) { | ||
console.log(formatMessage('TRACE', ...args)); | ||
} | ||
} | ||
|
||
debug(...args: unknown[]): void { | ||
if (this.isEnabled('DEBUG')) { | ||
console.log(formatMessage('DEBUG', ...args)); | ||
} | ||
} | ||
|
||
info(...args: unknown[]): void { | ||
if (this.isEnabled('INFO')) { | ||
console.log(formatMessage('INFO', ...args)); | ||
} | ||
} | ||
|
||
warn(...args: unknown[]): void { | ||
if (this.isEnabled('WARN')) { | ||
console.log(formatMessage('WARN', ...args)); | ||
} | ||
} | ||
|
||
error(...args: unknown[]): void { | ||
if (this.isEnabled('ERROR')) { | ||
console.error(formatMessage('ERROR', ...args)); | ||
} | ||
} | ||
|
||
fatal(...args: unknown[]): void { | ||
if (this.isEnabled('FATAL')) { | ||
console.error(formatMessage('FATAL', ...args)); | ||
} | ||
} | ||
|
||
private isEnabled(level: LogLevel): boolean { | ||
const levels: LogLevel[] = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']; | ||
const currentLevel = process.env.LOG_LEVEL?.toUpperCase() as LogLevel || this.level; | ||
return levels.indexOf(level) >= levels.indexOf(currentLevel); | ||
} | ||
} | ||
|
||
export const logger = new Logger(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { Buffer } from 'node:buffer'; | ||
|
||
/** | ||
* Sanitises filenames by removing/replacing illegal characters. | ||
* Based on sanitize-filename package but implemented natively. | ||
* | ||
* Handles: | ||
* - Illegal chars: /\?<>:\*|" | ||
* - Control chars: C0 (0x00-0x1f) & C1 (0x80-0x9f) | ||
* - Reserved names: CON, PRN, AUX, etc. | ||
* - Max length: 255 bytes | ||
*/ | ||
export function sanitiseFilename(input: string, options: { replacement?: string } = {}): string { | ||
if (typeof input !== 'string') { | ||
throw new Error('Input must be string'); | ||
} | ||
|
||
const replacement = options.replacement ?? ''; | ||
|
||
// Split into separate regexes for clarity | ||
const illegalRe = /[/\\?<>:*|"]/g; | ||
const controlRe = /[\x00-\x1f\x80-\x9f]/g; | ||
const reservedRe = /^\.+$/; | ||
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; | ||
const windowsTrailingRe = /[\. ]+$/; | ||
|
||
// First pass with replacement character | ||
let sanitised = input | ||
.replace(illegalRe, replacement) | ||
.replace(controlRe, replacement) | ||
.replace(reservedRe, replacement) | ||
.replace(windowsReservedRe, replacement) | ||
.replace(windowsTrailingRe, replacement); | ||
|
||
// Limit to 255 bytes while respecting UTF-8 | ||
if (Buffer.byteLength(sanitised) > 255) { | ||
let bytes = 0; | ||
let result = ''; | ||
for (const char of sanitised) { | ||
const charBytes = Buffer.byteLength(char); | ||
if (bytes + charBytes > 255) break; | ||
bytes += charBytes; | ||
result += char; | ||
} | ||
sanitised = result; | ||
} | ||
|
||
// Second pass with empty replacement if we used a replacement char | ||
if (replacement !== '') { | ||
sanitised = sanitised | ||
.replace(illegalRe, '') | ||
.replace(controlRe, '') | ||
.replace(reservedRe, '') | ||
.replace(windowsReservedRe, '') | ||
.replace(windowsTrailingRe, ''); | ||
} | ||
|
||
return sanitised; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import getClient from './services/getClient'; | ||
import getUserLikes from './services/getUserLikes'; | ||
import getMissingMusic from './services/getMissingMusic'; | ||
import { logger } from './helpers/logger'; | ||
import { SoundCloudSyncOptions } from './types'; | ||
|
||
export { getClient, getUserLikes, getMissingMusic }; | ||
|
||
export async function soundCloudSync({ username, folder = './music', limit = 50 }: SoundCloudSyncOptions) { | ||
logger.info(`Getting latest likes for ${username}`); | ||
|
||
try { | ||
const client = await getClient(username); | ||
const userLikes = await getUserLikes(client, '0', limit); | ||
await getMissingMusic(userLikes, folder, { | ||
onDownloadStart: (title) => logger.info(`Downloading ${title}`), | ||
onDownloadComplete: (title) => logger.info(`Added ${title}`), | ||
onDownloadError: (title, error) => | ||
logger.error(`Failed to download ${title}`, { error: error instanceof Error ? error.message : String(error) }), | ||
}); | ||
} catch (error) { | ||
logger.error('An error occurred', { error: error instanceof Error ? error.message : String(error) }); | ||
throw error; | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.