├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── Serato Demo Tracks.crate ├── index.js ├── package-lock.json ├── package.json ├── tests ├── seratojs.test.js ├── util.test.js └── utils.js └── util.js /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: macos-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [10.x, 12.x, 14.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm install 20 | - run: npm run build --if-present 21 | - run: npm test -- --runInBand 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bryan Collazo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # seratojs 2 | 3 | Manage Serato Crates Programatically in NodeJS. 4 | 5 | ## Installing 6 | 7 | ``` 8 | npm install seratojs 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```javascript 14 | const seratojs = require("seratojs"); 15 | 16 | // List all crates defined by user. 17 | const crates = seratojs.listCratesSync(); 18 | console.log(crates); 19 | 20 | // List all song filepaths in a given crate. 21 | const crate = crates[0]; 22 | const songs = crate.getSongPathsSync(); 23 | console.log(songs); 24 | 25 | // Create a crate 26 | const newCrate = new seratojs.Crate("ProgramaticallyCreatedCrate"); 27 | newCrate.addSong("Users/bcollazo/Music/song.mp3"); 28 | newCrate.addSong("C:\\Users\\bcollazo\\Music\\second_song.mp3"); 29 | newCrate.saveSync(); 30 | ``` 31 | 32 | Asynchronous (await-async / promise-based) API: 33 | 34 | ```javascript 35 | const seratojs = require("seratojs"); 36 | 37 | (async function () { 38 | const crates = await seratojs.listCrates(); 39 | const songs = await crates[0].getSongPaths(); 40 | const newCrate = new seratojs.Crate("ProgramaticallyCreatedCrate"); 41 | newCrate.addSong("Users/bcollazo/Music/song.mp3"); 42 | await newCrate.save(); 43 | })(); 44 | ``` 45 | 46 | Adding songs from different drives will replicate Serato's behavior 47 | of saving the crate in all drives participating in the crate. 48 | 49 | ```javascript 50 | const crate = new seratojs.Crate("MyCrate"); 51 | crate.addSong("D:\\Music\\song1.mp3"); 52 | crate.addSong("C:\\Users\\bcollazo\\Music\\song2.mp3"); 53 | crate.saveSync(); // will save in D:\\_Serato_ and C:\\Users\\bcollazo\\Music\\_Serato_ 54 | ``` 55 | 56 | ## Notes 57 | 58 | SeratoJS tries to sanitize crate name before creation. This is to allow crates named 'Some / Name' to be created without giving trouble. It will be created as 'Some - Name' instead. 59 | 60 | ### Migrating from 1.x to 2.x 61 | 62 | - Change `crate.getSongPaths()` to `crate.getSongPathsSync()` or `await crate.getSongPaths()`. 63 | - Change `newCrate.save()` to `newCrate.saveSync()` or `await newCrate.save()`. 64 | -------------------------------------------------------------------------------- /Serato Demo Tracks.crate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcollazo/seratojs/07e5e39aeee9dbe21305713c25660cae57056edb/Serato Demo Tracks.crate -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const util = require("util"); 3 | const os = require("os"); 4 | const path = require("path"); 5 | 6 | const { 7 | parse, 8 | toSeratoString, 9 | intToHexbin, 10 | sanitizeFilename, 11 | removeDriveRoot, 12 | selectExternalRoot, 13 | isFromExternalDrive, 14 | } = require("./util"); 15 | 16 | // Singleton for Serato Folder Path (I doubt it'll change during runtime) 17 | const PLATFORM_DEFAULT_SERATO_FOLDER = path.join( 18 | os.homedir(), 19 | "Music", 20 | "_Serato_" 21 | ); 22 | 23 | function getSubcratesFolder(seratoFolder) { 24 | return path.join(seratoFolder, "SubCrates"); 25 | } 26 | 27 | /** 28 | * For each Serato Folder location, collect crates and returns a list 29 | * of all of these. 30 | */ 31 | function listCratesSync(seratoFolders = [PLATFORM_DEFAULT_SERATO_FOLDER]) { 32 | const allCrates = []; 33 | seratoFolders.forEach((seratoFolder) => { 34 | const subcratesFolder = getSubcratesFolder(seratoFolder); 35 | const crates = fs.readdirSync(subcratesFolder).map((x) => { 36 | const name = path.basename(x, ".crate"); 37 | return new Crate(name, seratoFolder); 38 | }); 39 | allCrates.push(...crates); 40 | }); 41 | return allCrates; 42 | } 43 | 44 | async function listCrates(seratoFolders = [PLATFORM_DEFAULT_SERATO_FOLDER]) { 45 | const allCrates = []; 46 | for (const seratoFolder of seratoFolders) { 47 | const subcratesFolder = getSubcratesFolder(seratoFolder); 48 | const files = await util.promisify(fs.readdir)(subcratesFolder); 49 | const crates = files.map((x) => { 50 | const name = path.basename(x, ".crate"); 51 | return new Crate(name, seratoFolder); 52 | }); 53 | allCrates.push(...crates); 54 | } 55 | return allCrates; 56 | } 57 | 58 | class Crate { 59 | /** 60 | * Serato saves crates in all the drives from which songs 61 | * in the crate come from. When you create a seratojs.Crate 62 | * it assumes we are dealing with a Music-folder-main-drive crate. 63 | * 64 | * You can "fix" this crate to represent a particular crate in 65 | * one particular Serato folder; in which case saving will use 66 | * that location only. You are responsible for adding songs 67 | * compatible with that drive. This is what we call 'location-aware' 68 | * crates. 69 | */ 70 | constructor(name, seratoFolder) { 71 | // TODO: Make private 72 | this.name = sanitizeFilename(name); 73 | this.filename = this.name + ".crate"; 74 | this.songPaths = []; 75 | 76 | this.seratoFolder = seratoFolder; // To override for testing... 77 | } 78 | 79 | /** 80 | * Returns the Serato directories where this will be saved. 81 | */ 82 | getSaveLocations() { 83 | if (this.seratoFolder) { 84 | return [this.seratoFolder]; // if specified at construction use this only. 85 | } 86 | 87 | if (this.songPaths.length === 0) { 88 | return [PLATFORM_DEFAULT_SERATO_FOLDER]; 89 | } 90 | 91 | const roots = new Set(); 92 | this.songPaths.forEach((songPath) => { 93 | if (isFromExternalDrive(songPath)) { 94 | const externalRoot = selectExternalRoot(songPath); 95 | roots.add(path.join(externalRoot, "_Serato_")); 96 | } else { 97 | roots.add(PLATFORM_DEFAULT_SERATO_FOLDER); 98 | } 99 | }); 100 | return Array.from(roots); 101 | } 102 | 103 | // TODO: When reading, where should it read from? 104 | async getSongPaths() { 105 | const filepath = this._buildCrateFilepath( 106 | this.seratoFolder || PLATFORM_DEFAULT_SERATO_FOLDER 107 | ); 108 | const contents = await util.promisify(fs.readFile)(filepath, "ascii"); 109 | return parse(contents); 110 | } 111 | getSongPathsSync() { 112 | const filepath = this._buildCrateFilepath( 113 | this.seratoFolder || PLATFORM_DEFAULT_SERATO_FOLDER 114 | ); 115 | const contents = fs.readFileSync(filepath, "ascii"); 116 | return parse(contents); 117 | } 118 | 119 | addSong(songPath) { 120 | if (this.songPaths === null) { 121 | this.songPaths = []; 122 | } 123 | 124 | const resolved = path.resolve(songPath); 125 | this.songPaths.push(resolved); 126 | } 127 | 128 | _buildCrateFilepath(seratoFolder) { 129 | const subcrateFolder = getSubcratesFolder(seratoFolder); 130 | const filepath = path.join(subcrateFolder, this.filename); 131 | return filepath; 132 | } 133 | _buildSaveBuffer() { 134 | const header = 135 | "vrsn 8 1 . 0 / S e r a t o S c r a t c h L i v e C r a t e".replace( 136 | / /g, 137 | "\0" 138 | ); 139 | 140 | let playlistSection = ""; 141 | if (this.songPaths) { 142 | this.songPaths.forEach((songPath) => { 143 | const absoluteSongPath = path.resolve(songPath); 144 | const songPathWithoutDrive = removeDriveRoot(absoluteSongPath); 145 | const data = toSeratoString(songPathWithoutDrive); 146 | let ptrkSize = intToHexbin(data.length); 147 | let otrkSize = intToHexbin(data.length + 8); // fixing the +8 (4 for 'ptrk', 4 for ptrkSize) 148 | playlistSection += "otrk" + otrkSize + "ptrk" + ptrkSize + data; 149 | }); 150 | } 151 | 152 | const contents = header + playlistSection; 153 | return Buffer.from(contents, "ascii"); 154 | } 155 | 156 | async save() { 157 | for (const seratoFolder of this.getSaveLocations()) { 158 | const filepath = this._buildCrateFilepath(seratoFolder); 159 | const buffer = this._buildSaveBuffer(); 160 | 161 | return util.promisify(fs.writeFile)(filepath, buffer, { 162 | encoding: null, 163 | }); 164 | } 165 | } 166 | saveSync() { 167 | for (const seratoFolder of this.getSaveLocations()) { 168 | const filepath = this._buildCrateFilepath(seratoFolder); 169 | const buffer = this._buildSaveBuffer(); 170 | 171 | // Ensure folder exists 172 | fs.writeFileSync(filepath, buffer, { encoding: null }); 173 | } 174 | } 175 | } 176 | 177 | const seratojs = { 178 | Crate: Crate, 179 | listCratesSync: listCratesSync, 180 | listCrates: listCrates, 181 | }; 182 | 183 | module.exports = seratojs; 184 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seratojs", 3 | "version": "3.2.0", 4 | "description": "Manage Serato Crates programatically via NodeJS.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/bcollazo/seratojs.git" 12 | }, 13 | "keywords": [ 14 | "serato", 15 | "javascript", 16 | "music", 17 | "crates", 18 | "dj" 19 | ], 20 | "author": "Bryan Collazo", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/bcollazo/seratojs/issues" 24 | }, 25 | "homepage": "https://github.com/bcollazo/seratojs#readme", 26 | "devDependencies": { 27 | "jest": "^27.3.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/seratojs.test.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | 5 | const seratojs = require("../index"); 6 | const { localPath, externalPath } = require("./utils"); 7 | 8 | /** 9 | * These tests create a folder in the repo root called "_TestSerato_" 10 | * and populates it with 1 crate before each test. Tests usually use this 11 | * instead of the default ones. 12 | */ 13 | const TEST_SERATO_FOLDER = path.join(".", "_TestSerato_"); 14 | const TEST_SUBCRATES_FOLDER = path.join(TEST_SERATO_FOLDER, "Subcrates"); 15 | 16 | function safelyDeleteSeratoFolder(folder) { 17 | const subCrateFolder = path.join(folder, "Subcrates"); 18 | const files = fs.readdirSync(subCrateFolder); 19 | for (let filename of files) { 20 | fs.unlinkSync(path.join(subCrateFolder, filename)); 21 | } 22 | fs.rmdirSync(subCrateFolder); 23 | fs.rmdirSync(folder); 24 | } 25 | 26 | beforeEach(() => { 27 | fs.mkdirSync(TEST_SERATO_FOLDER); 28 | fs.mkdirSync(TEST_SUBCRATES_FOLDER); 29 | fs.copyFileSync( 30 | path.join(".", "Serato Demo Tracks.crate"), 31 | path.join(TEST_SUBCRATES_FOLDER, "Serato Demo Tracks.crate") 32 | ); 33 | }); 34 | 35 | afterEach(() => { 36 | safelyDeleteSeratoFolder(TEST_SERATO_FOLDER); 37 | }); 38 | 39 | // ===== List crates 40 | test("list crates in sync", () => { 41 | const crates = seratojs.listCratesSync([TEST_SERATO_FOLDER]); 42 | expect(crates.length).toBe(1); 43 | }); 44 | 45 | test("async list crates and sync song paths", async () => { 46 | const crates = await seratojs.listCrates([TEST_SERATO_FOLDER]); 47 | expect(crates.length).toBe(1); 48 | 49 | const crate = crates[0]; 50 | const songs = crate.getSongPathsSync(); 51 | 52 | const baseFolder = localPath( 53 | "/Users/bcollazo/Music/_Serato_/Imported/Serato Demo Tracks" 54 | ); 55 | expect(crate.name).toBe("Serato Demo Tracks"); 56 | expect(songs).toEqual([ 57 | path.resolve(baseFolder, "01 - House Track Serato House Starter Pack.mp3"), 58 | path.resolve(baseFolder, "02 - House Track Serato House Starter Pack.mp3"), 59 | path.resolve(baseFolder, "03 - House Track Serato House Starter Pack.mp3"), 60 | path.resolve( 61 | baseFolder, 62 | "04 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 63 | ), 64 | path.resolve( 65 | baseFolder, 66 | "05 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 67 | ), 68 | path.resolve( 69 | baseFolder, 70 | "06 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 71 | ), 72 | ]); 73 | }); 74 | 75 | // ===== Save locations 76 | test("adding songs from a drive, saves it in drive", () => { 77 | const crate = new seratojs.Crate("TestDriveCrate"); 78 | crate.addSong(externalPath("TestFolder/song1.mp3")); 79 | crate.addSong(externalPath("song2.mp3")); 80 | 81 | const locations = crate.getSaveLocations(); 82 | expect(locations.length).toBe(1); 83 | expect(locations[0]).toBe(externalPath("_Serato_")); 84 | }); 85 | 86 | test("adding songs from a drive and local disk, saves it in both", () => { 87 | const crate = new seratojs.Crate("TestDriveCrate"); 88 | crate.addSong(externalPath("TestFolder/song1.mp3")); 89 | crate.addSong(localPath("/Users/bcollazo/Music/song2.mp3")); 90 | 91 | const locations = crate.getSaveLocations(); 92 | expect(locations.length).toBe(2); 93 | expect(locations).toContain(externalPath("_Serato_")); 94 | expect(locations).toContain(path.join(os.homedir(), "Music", "_Serato_")); 95 | }); 96 | 97 | test("adding songs from local disk only, saves it Music folder _Serato_", () => { 98 | const crate = new seratojs.Crate("TestDriveCrate"); 99 | crate.addSong("C:\\Users\\bcollazo\\Music\\folder\\song1.mp3"); 100 | crate.addSong("C:\\Users\\bcollazo\\Music\\song2.mp3"); 101 | 102 | const locations = crate.getSaveLocations(); 103 | expect(locations.length).toBe(1); 104 | expect(locations).toContain(path.join(os.homedir(), "Music", "_Serato_")); 105 | }); 106 | 107 | test("new empty crate saves it Music folder _Serato_", () => { 108 | const crate = new seratojs.Crate("TestDriveCrate"); 109 | 110 | const locations = crate.getSaveLocations(); 111 | expect(locations.length).toBe(1); 112 | expect(locations).toContain(path.join(os.homedir(), "Music", "_Serato_")); 113 | }); 114 | 115 | test("if specify serato folder at creation, saving will use that one. no matter contents", () => { 116 | const crate = new seratojs.Crate("TestDriveCrate", TEST_SERATO_FOLDER); 117 | crate.addSong("D:\\TestFolder\\song1.mp3"); 118 | crate.addSong("C:\\Users\\bcollazo\\Music\\song2.mp3"); 119 | 120 | const locations = crate.getSaveLocations(); 121 | expect(locations.length).toBe(1); 122 | expect(locations).toContain(TEST_SERATO_FOLDER); 123 | }); 124 | 125 | // ===== Save songs. Can mock and listing crates matches. 126 | test("IntegrationTest: create new crate, add songs, list crates, list songs", () => { 127 | const crate = new seratojs.Crate( 128 | "ProgramaticallyCreatedCrate", 129 | TEST_SERATO_FOLDER 130 | ); 131 | crate.addSong("C:\\Users\\bcollazo\\Music\\second_song.mp3"); 132 | crate.saveSync(); // saves to C:\\ 133 | 134 | const crates = seratojs.listCratesSync([TEST_SERATO_FOLDER]); 135 | expect(crates.length).toBe(2); 136 | const songPaths = crate.getSongPathsSync(); 137 | expect(songPaths.length).toBe(1); 138 | }); 139 | 140 | test("IntegrationTest: async mac create new crate, add songs, list crates, list songs", async () => { 141 | const crate = new seratojs.Crate( 142 | "ProgramaticallyCreatedCrate", 143 | TEST_SERATO_FOLDER 144 | ); 145 | crate.addSong("Users/bcollazo/Music/song.mp3"); 146 | crate.addSong("/Users/bcollazo/Music/second_song.mp3"); 147 | await crate.save(); 148 | 149 | const crates = await seratojs.listCrates([TEST_SERATO_FOLDER]); 150 | expect(crates.length).toBe(2); 151 | const songPaths = await crate.getSongPaths(); 152 | expect(songPaths.length).toBe(2); 153 | }); 154 | 155 | // ===== Read song lists 156 | test("read crate info", () => { 157 | const crate = seratojs.listCratesSync([TEST_SERATO_FOLDER])[0]; 158 | const songs = crate.getSongPathsSync(); 159 | 160 | const baseFolder = localPath( 161 | "/Users/bcollazo/Music/_Serato_/Imported/Serato Demo Tracks" 162 | ); 163 | expect(crate.name).toBe("Serato Demo Tracks"); 164 | expect(songs).toEqual([ 165 | path.resolve(baseFolder, "01 - House Track Serato House Starter Pack.mp3"), 166 | path.resolve(baseFolder, "02 - House Track Serato House Starter Pack.mp3"), 167 | path.resolve(baseFolder, "03 - House Track Serato House Starter Pack.mp3"), 168 | path.resolve( 169 | baseFolder, 170 | "04 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 171 | ), 172 | path.resolve( 173 | baseFolder, 174 | "05 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 175 | ), 176 | path.resolve( 177 | baseFolder, 178 | "06 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 179 | ), 180 | ]); 181 | }); 182 | 183 | test("async read song paths", async () => { 184 | const crate = (await seratojs.listCrates([TEST_SERATO_FOLDER]))[0]; 185 | const songs = await crate.getSongPaths(); 186 | 187 | const baseFolder = localPath( 188 | "/Users/bcollazo/Music/_Serato_/Imported/Serato Demo Tracks" 189 | ); 190 | expect(crate.name).toBe("Serato Demo Tracks"); 191 | expect(songs).toEqual([ 192 | path.resolve(baseFolder, "01 - House Track Serato House Starter Pack.mp3"), 193 | path.resolve(baseFolder, "02 - House Track Serato House Starter Pack.mp3"), 194 | path.resolve(baseFolder, "03 - House Track Serato House Starter Pack.mp3"), 195 | path.resolve( 196 | baseFolder, 197 | "04 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 198 | ), 199 | path.resolve( 200 | baseFolder, 201 | "05 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 202 | ), 203 | path.resolve( 204 | baseFolder, 205 | "06 - Hip Hop Track Serato Hip Hop Starter Pack.mp3" 206 | ), 207 | ]); 208 | }); 209 | 210 | test("weird names dont break crate creation", async () => { 211 | const newCrate = new seratojs.Crate( 212 | "2000-2010 HipHáp / Reggaeton!?", 213 | TEST_SERATO_FOLDER 214 | ); 215 | await newCrate.save(); 216 | }); 217 | 218 | // test("async create when Serato folder doesnt exist", async () => { 219 | // const newCrate = new seratojs.Crate( 220 | // "TestCrateSeratoFolderNonExistent", 221 | // NON_EXISTENT_SERATO_FOLDER 222 | // ); 223 | // await newCrate.save(); 224 | // safelyDeleteSeratoFolder(NON_EXISTENT_SERATO_FOLDER); 225 | // }); 226 | 227 | // test("create when Serato folder doesnt exist", async () => { 228 | // const newCrate = new seratojs.Crate( 229 | // "TestCrateSeratoFolderNonExistent", 230 | // NON_EXISTENT_SERATO_FOLDER 231 | // ); 232 | // newCrate.saveSync(); 233 | // safelyDeleteSeratoFolder(NON_EXISTENT_SERATO_FOLDER); 234 | // }); 235 | -------------------------------------------------------------------------------- /tests/util.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | sanitizeFilename, 3 | removeDriveRoot, 4 | isFromExternalDrive, 5 | } = require("../util"); 6 | 7 | test("isFromExternalDrive", () => { 8 | expect(isFromExternalDrive("C:\\Users\\bcollazo", "win32")).toBe(false); 9 | expect(isFromExternalDrive("D:\\Users\\bcollazo", "win32")).toBe(true); 10 | expect(isFromExternalDrive("/Users/bcollazo/song.mp3", "darwin")).toBe(false); 11 | expect(isFromExternalDrive("/Volumes/TestUsb/Music/song.mp3", "darwin")).toBe( 12 | true 13 | ); 14 | }); 15 | 16 | describe("removeDriveRoot", () => { 17 | test("local darwin path does nothing", () => { 18 | const fixture = "/Users/bcollazo/Music"; 19 | const result = removeDriveRoot(fixture, "darwin"); 20 | expect(result).toBe("/Users/bcollazo/Music"); 21 | }); 22 | 23 | test("external darwin path removes /Volumes/TestUsb", () => { 24 | const fixture = "/Volumes/TestUsb/Music/Folder/song.mp3"; 25 | const result = removeDriveRoot(fixture, "darwin"); 26 | expect(result).toBe("/Music/Folder/song.mp3"); 27 | }); 28 | 29 | test("local windows path removes C:", () => { 30 | const fixture = "C:\\Users\\bcollazo\\Music"; 31 | const result = removeDriveRoot(fixture, "win32"); 32 | expect(result).toBe("Users\\bcollazo\\Music"); 33 | }); 34 | 35 | test("external windows path removes D:", () => { 36 | const fixture = "D:\\Users\\bcollazo\\Music"; 37 | const result = removeDriveRoot(fixture, "win32"); 38 | expect(result).toBe("Users\\bcollazo\\Music"); 39 | }); 40 | }); 41 | 42 | test("util filename sanitazion", () => { 43 | expect(sanitizeFilename("hello/world")).toBe("hello-world"); 44 | expect(sanitizeFilename("hello/wo rl/d")).toBe("hello-wo rl-d"); 45 | expect(sanitizeFilename("hello-world")).toBe("hello-world"); 46 | expect(sanitizeFilename("foo bar baz")).toBe("foo bar baz"); 47 | expect(sanitizeFilename("Foo BAR bAz")).toBe("Foo BAR bAz"); 48 | expect(sanitizeFilename("Foo BAR.bAz")).toBe("Foo BAR-bAz"); 49 | expect(sanitizeFilename("Foo_BAR.bAz")).toBe("Foo_BAR-bAz"); 50 | expect(sanitizeFilename("Foo_BAR.bAz!")).toBe("Foo_BAR-bAz-"); 51 | expect(sanitizeFilename("!Viva Latino!")).toBe("-Viva Latino-"); 52 | expect(sanitizeFilename("2000-2010 HipHop / Reggae")).toBe( 53 | "2000-2010 HipHop - Reggae" 54 | ); 55 | expect(sanitizeFilename("Activáera!?")).toBe("Activ-era--"); 56 | expect(sanitizeFilename("2000-2010 HipHáp / Reggaeton!?")).toBe( 57 | "2000-2010 HipH-p - Reggaeton--" 58 | ); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | function externalPath(posixPath) { 4 | if (process.platform === "win32") { 5 | return path.resolve("D:\\", posixPath); 6 | } else if (process.platform === "darwin") { 7 | return path.resolve("/Volumes/SampleExternalHardDrive", posixPath); 8 | } else { 9 | throw new Error("Not Implemented"); 10 | } 11 | } 12 | 13 | function localPath(posixPath) { 14 | if (process.platform === "win32") { 15 | return path.resolve("C:\\", posixPath); 16 | } else if (process.platform === "darwin") { 17 | return path.resolve("/", posixPath); 18 | } else { 19 | throw new Error("Not Implemented"); 20 | } 21 | } 22 | 23 | module.exports = { 24 | externalPath, 25 | localPath, 26 | }; 27 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const INVALID_CHARACTERS_REGEX = /[^A-Za-z0-9_ ]/gi; 4 | 5 | const parse = function (contents) { 6 | // Find all 'ptrk' ocurrances 7 | const indices = []; 8 | for (let i = 0; i < contents.length; i++) { 9 | if (contents.slice(i, i + 4) === "ptrk") { 10 | indices.push(i); 11 | } 12 | } 13 | 14 | // Content in between these indices are the songs 15 | const songs = []; 16 | indices.forEach((value, index) => { 17 | const start = value + 9; // + 9 to skip the 'ptrk' itself and the bytes for size 18 | const isLast = index === indices.length - 1; 19 | const end = isLast ? contents.length : indices[index + 1] - 8; // -8 to remove 'otrk' and size bytes 20 | 21 | let filepath = contents.slice(start, end); 22 | filepath = filepath.replace(/\0/g, ""); // remove null-termination bytes 23 | songs.push(path.resolve("/", filepath)); 24 | }); 25 | return songs; 26 | }; 27 | 28 | const toSeratoString = function (string) { 29 | return "\0" + string.split("").join("\0"); 30 | }; 31 | 32 | const intToHexbin = function (number) { 33 | const hex = number.toString(16).padStart(8, "0"); 34 | let ret = ""; 35 | for (let idx of [0, 2, 4, 6]) { 36 | let bytestr = hex.slice(idx, idx + 2); 37 | ret += String.fromCodePoint(parseInt(bytestr, 16)); 38 | } 39 | return ret; 40 | }; 41 | 42 | const sanitizeFilename = function (filename) { 43 | return filename.replace(INVALID_CHARACTERS_REGEX, "-"); 44 | }; 45 | 46 | /** Second param for dependency injection testing */ 47 | function removeDriveRoot(absoluteSongPath, platformParam = null) { 48 | const platform = platformParam || process.platform; 49 | if (platform === "win32") { 50 | return absoluteSongPath.substring(3); // remove the C: or D: or ... 51 | } else { 52 | if (isFromExternalDrive(absoluteSongPath, platform)) { 53 | const externalDrive = selectExternalRoot(absoluteSongPath, platform); 54 | return absoluteSongPath.substring(externalDrive.length); 55 | } else { 56 | return absoluteSongPath; 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * Assumes input is an external path 63 | * @returns external volume prefix string for external drive paths 64 | * 65 | * e.g. 66 | * /Volumes/SampleDrive/Some/Path.mp3 => /Volumes/SampleDrive 67 | * D:\\Folder\\song.mp3 => D:\\ 68 | */ 69 | function selectExternalRoot(externalSongPath, platformParam = null) { 70 | const platform = platformParam || process.platform; 71 | if (platform === "win32") { 72 | return path.parse(externalSongPath).root; 73 | } else { 74 | return externalSongPath.split("/").slice(0, 3).join("/"); 75 | } 76 | } 77 | 78 | function isFromExternalDrive(songPath, platformParam = null) { 79 | const platform = platformParam || process.platform; 80 | return ( 81 | (platform === "win32" && !songPath.startsWith("C:\\")) || 82 | (platform === "darwin" && songPath.startsWith("/Volumes")) 83 | ); 84 | } 85 | 86 | module.exports = { 87 | parse, 88 | removeDriveRoot, 89 | toSeratoString, 90 | intToHexbin, 91 | sanitizeFilename, 92 | selectExternalRoot, 93 | isFromExternalDrive, 94 | }; 95 | --------------------------------------------------------------------------------