Skip to content

Commit

Permalink
typescript refactor, add deno support
Browse files Browse the repository at this point in the history
  • Loading branch information
realies committed Dec 23, 2024
1 parent 9e62b64 commit d866f44
Show file tree
Hide file tree
Showing 24 changed files with 873 additions and 2,089 deletions.
12 changes: 0 additions & 12 deletions .babelrc

This file was deleted.

1 change: 0 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"plugin:import/errors",
"plugin:prettier/recommended"
],
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 9
},
Expand Down
55 changes: 52 additions & 3 deletions README.md
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.
4 changes: 4 additions & 0 deletions bin/soundcloud-sync.js
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');
14 changes: 14 additions & 0 deletions deno.json
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
}
}
2 changes: 2 additions & 0 deletions mod.ts
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';
24 changes: 7 additions & 17 deletions package.json
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"
}
}
20 changes: 20 additions & 0 deletions src/cli.ts
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);
}
})();
13 changes: 0 additions & 13 deletions src/helpers/logger.js

This file was deleted.

75 changes: 75 additions & 0 deletions src/helpers/logger.ts
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();
59 changes: 59 additions & 0 deletions src/helpers/sanitise.ts
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;
}
25 changes: 25 additions & 0 deletions src/index.ts
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;
}
}
19 changes: 0 additions & 19 deletions src/server.js

This file was deleted.

21 changes: 0 additions & 21 deletions src/services/getClient.js

This file was deleted.

Loading

0 comments on commit d866f44

Please sign in to comment.