tserato 0.1.0 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/builder.ts ADDED
@@ -0,0 +1,198 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { BaseEncoder } from './encoders/baseEncoder';
4
+ import { Crate } from './model/crate';
5
+ import { Track } from './model/track';
6
+ import { seratoEncode, seratoDecode, concatBytes, intToBytes, latin1Encode } from './util';
7
+
8
+ export const DEFAULT_SERATO_FOLDER = path.join(
9
+ process.env.HOME ?? process.cwd(),
10
+ 'Music',
11
+ '_Serato_'
12
+ );
13
+
14
+ export class Builder {
15
+ private _encoder?: BaseEncoder;
16
+ constructor(encoder?: BaseEncoder) { this._encoder = encoder; }
17
+
18
+ private static *_resolvePath(root: Crate): Generator<[Crate, string]> {
19
+ let pathStr = '';
20
+ const stack: Array<[Crate, string]> = [[root, pathStr]];
21
+ while (stack.length) {
22
+ const [crate, p] = stack.pop() as [Crate, string];
23
+ let newPath = p + `${crate.name}%%`;
24
+ const children = crate.children;
25
+ for (const child of children.values()) {
26
+ stack.push([child, newPath]);
27
+ }
28
+ yield [crate, newPath.replace(/%%$/, '') + '.crate'];
29
+ }
30
+ }
31
+
32
+ private static *_parseCrateNames(filepath: string): Generator<string> {
33
+ const parts = path.basename(filepath).split('%%');
34
+ for (const p of parts) yield p.replace('.crate', '');
35
+ }
36
+
37
+ private *_buildCrateFilepath(crate: Crate, seratoFolder: string): Generator<[Crate, string]> {
38
+ const subcrateFolder = path.join(seratoFolder, 'SubCrates');
39
+ if (!fs.existsSync(subcrateFolder)) fs.mkdirSync(subcrateFolder, { recursive: true });
40
+ for (const [c, p] of Builder._resolvePath(crate)) {
41
+ yield [c, path.join(subcrateFolder, p)];
42
+ }
43
+ }
44
+
45
+ parseCratesFromRootPath(subcratePath: string): Map<string, Crate> {
46
+ // map from top-level crate name to crate
47
+ const topLevelCrateMap = new Map<string, Crate>();
48
+
49
+ for (const entry of fs.readdirSync(subcratePath, { withFileTypes: true })) {
50
+ if (!entry.isFile() || !entry.name.endsWith("crate")) {
51
+ continue;
52
+ }
53
+
54
+ const fullPath = path.join(subcratePath, entry.name);
55
+ const crate = this._buildCratesFromFilepath(fullPath, topLevelCrateMap);
56
+
57
+ if (!topLevelCrateMap.has(crate.name)) {
58
+ topLevelCrateMap.set(crate.name, crate);
59
+ }
60
+ }
61
+
62
+ return topLevelCrateMap;
63
+ }
64
+
65
+ private _buildCratesFromFilepath(
66
+ filepath: string,
67
+ topLevelCrateMap: Map<string, Crate>,
68
+ ): Crate {
69
+ const crateNames = Array.from(Builder._parseCrateNames(filepath));
70
+ if (crateNames.length === 0) {
71
+ throw new Error(`No crates parsed from ${filepath}`);
72
+ }
73
+
74
+ const tracks: Track[] = [];
75
+ for (const p of this._parseCrateTracks(filepath)) {
76
+ console.log('make track from path ', p, 'from file path ', filepath)
77
+ tracks.push(Track.fromPath(p));
78
+ }
79
+
80
+ let root = topLevelCrateMap.get(crateNames[0]);
81
+ if (!root) {
82
+ root = new Crate(crateNames[0]);
83
+ }
84
+
85
+ let current = root;
86
+
87
+ for (const crateName of crateNames.slice(1)) {
88
+ let nextCrate = current.children.get(crateName);
89
+ if (!nextCrate) {
90
+ nextCrate = new Crate(crateName);
91
+ current.children.set(crateName, nextCrate);
92
+ }
93
+ current = nextCrate;
94
+ }
95
+
96
+ for (const track of tracks) {
97
+ current.addTrack(track);
98
+ }
99
+
100
+ return root;
101
+ }
102
+
103
+
104
+ private *_parseCrateTracks(filepath: string): Generator<string> {
105
+ let buffer = fs.readFileSync(filepath);
106
+ const OTRK = Buffer.from("otrk", "utf8");
107
+ const PTRK = Buffer.from("ptrk", "utf8");
108
+ while (buffer.length > 0) {
109
+ const otrkIdx = buffer.indexOf(OTRK);
110
+ if (otrkIdx < 0) break;
111
+
112
+ const ptrkIdx = buffer.indexOf(PTRK);
113
+ if (ptrkIdx < 0) break;
114
+
115
+ const ptrkSection = buffer.slice(ptrkIdx);
116
+
117
+ const trackNameLength = ptrkSection.readUInt32BE(4);
118
+ const trackNameEncoded = ptrkSection.slice(
119
+ 8,
120
+ 8 + trackNameLength,
121
+ );
122
+
123
+ let filePath = seratoDecode(trackNameEncoded);
124
+
125
+ if (!filePath.startsWith("/")) {
126
+ filePath = "/" + filePath;
127
+ }
128
+
129
+ yield filePath;
130
+
131
+ buffer = buffer.slice(ptrkIdx + 8 + trackNameLength);
132
+ }
133
+ }
134
+
135
+ private _construct(crate: Crate): Uint8Array {
136
+ // ----- HEADER -----
137
+ let header = concatBytes([
138
+ latin1Encode("vrsn"),
139
+ intToBytes(0, 1),
140
+ intToBytes(0, 1),
141
+ seratoEncode("81.0"),
142
+ seratoEncode("/Serato ScratchLive Crate"),
143
+ ]);
144
+
145
+ // ----- DEFAULT COLUMNS -----
146
+ const DEFAULT_COLUMNS = ["track", "artist", "album", "length"];
147
+
148
+ const parts: Uint8Array[] = [];
149
+ for (const column of DEFAULT_COLUMNS) {
150
+ parts.push(latin1Encode("ovct"));
151
+ parts.push(intToBytes(column.length * 2 + 18, 4));
152
+
153
+ parts.push(latin1Encode("tvcn"));
154
+ parts.push(intToBytes(column.length * 2, 4));
155
+ parts.push(seratoEncode(column));
156
+
157
+ parts.push(latin1Encode("tvcw"));
158
+ parts.push(intToBytes(2, 4));
159
+ parts.push(latin1Encode("0"));
160
+ parts.push(latin1Encode("0"));
161
+
162
+ }
163
+ const columnSection = concatBytes(parts);
164
+
165
+ // ----- PLAYLIST SECTION -----
166
+
167
+ const playlistParts: Uint8Array[] = [];
168
+ for (const track of crate.tracks) {
169
+ if (this._encoder) {
170
+ this._encoder.write(track);
171
+ }
172
+ const absoluteTrackPath = path.resolve(track.path);
173
+
174
+ const otrkSize = intToBytes(absoluteTrackPath.length * 2 + 8, 4);
175
+ const ptrkSize = intToBytes(absoluteTrackPath.length * 2, 4);
176
+
177
+ playlistParts.push(latin1Encode("otrk"));
178
+ playlistParts.push(otrkSize);
179
+ playlistParts.push(latin1Encode("ptrk"));
180
+ playlistParts.push(ptrkSize);
181
+ playlistParts.push(seratoEncode(absoluteTrackPath));
182
+ }
183
+ const playlistSection = concatBytes(playlistParts);
184
+
185
+ // ----- FINAL CONTENTS -----
186
+ const contents = concatBytes([header, columnSection, playlistSection]);
187
+ return contents;
188
+ }
189
+
190
+
191
+ save(root: Crate, savePath: string = DEFAULT_SERATO_FOLDER, overwrite = false): void {
192
+ for (const [crate, filepath] of this._buildCrateFilepath(root, savePath)) {
193
+ if (fs.existsSync(filepath) && !overwrite) continue;
194
+ const buffer = this._construct(crate);
195
+ fs.writeFileSync(filepath, buffer);
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,8 @@
1
+ import { Track } from '../model/track';
2
+
3
+ export abstract class BaseEncoder {
4
+ abstract get tagName(): string;
5
+ abstract get tagVersion(): Buffer;
6
+ abstract get markersName(): string;
7
+ abstract write(track: Track): void;
8
+ }
@@ -0,0 +1,4 @@
1
+ export const SERATO_MARKERS_V2 = 'Serato Markers2';
2
+ export const SERATO_OVERVIEW = 'Serato Overview';
3
+ export const SERATO_MARKERS_V1 = 'Serato Markers_';
4
+ export const SERATO_ANALYSIS = 'Serato Analysis';
@@ -0,0 +1,21 @@
1
+ export function encode(data: Buffer): Buffer {
2
+ const a = data.readUInt8(0);
3
+ const b = data.readUInt8(1);
4
+ const c = data.readUInt8(2);
5
+ const z = c & 0x7F;
6
+ const y = ((c >> 7) | (b << 1)) & 0x7F;
7
+ const x = ((b >> 6) | (a << 2)) & 0x7F;
8
+ const w = a >> 5;
9
+ return Buffer.from([w, x, y, z]);
10
+ }
11
+
12
+ export function decode(data: Buffer): Buffer {
13
+ const w = data.readUInt8(0);
14
+ const x = data.readUInt8(1);
15
+ const y = data.readUInt8(2);
16
+ const z = data.readUInt8(3);
17
+ const c = (z & 0x7F) | ((y & 0x01) << 7);
18
+ const b = ((y & 0x7F) >> 1) | ((x & 0x03) << 6);
19
+ const a = ((x & 0x7F) >> 2) | ((w & 0x07) << 5);
20
+ return Buffer.from([a, b, c]);
21
+ }
@@ -0,0 +1,230 @@
1
+ import { HotCue } from '../../../src/model/hotCue';
2
+ import { HotCueType } from '../../model/hotCueType';
3
+ import { Track } from '../../model/track';
4
+ import { BaseEncoder } from '../baseEncoder';
5
+ import { SERATO_MARKERS_V2 } from '../serato_tags';
6
+ import MP3Tag from 'mp3tag.js'
7
+ import { splitString } from '../../util';
8
+
9
+ const fs = require('fs')
10
+
11
+ /**
12
+ * Replacement for Python's BytesIO
13
+ */
14
+ class BufferReader {
15
+ private offset = 0;
16
+ constructor(private buffer: Buffer) {}
17
+
18
+ read(n: number): Buffer {
19
+ const chunk = this.buffer.slice(this.offset, this.offset + n);
20
+ this.offset += n;
21
+ return chunk;
22
+ }
23
+
24
+ readUInt8(): number {
25
+ const value = this.buffer.readUInt8(this.offset);
26
+ this.offset += 1;
27
+ return value;
28
+ }
29
+
30
+ readUInt32BE(): number {
31
+ const value = this.buffer.readUInt32BE(this.offset);
32
+ this.offset += 4;
33
+ return value;
34
+ }
35
+ }
36
+
37
+ export class V2Mp3Encoder extends BaseEncoder {
38
+
39
+ get tagName(): string {
40
+ return SERATO_MARKERS_V2;
41
+ }
42
+
43
+ get tagVersion(): Buffer {
44
+ return Buffer.from([0x01, 0x01]);
45
+ }
46
+
47
+ get markersName(): string {
48
+ return "Serato Markers2";
49
+ }
50
+
51
+ write(track: Track): void {
52
+ const payload = this._encode(track);
53
+ this._write(track, payload);
54
+ }
55
+
56
+ readCues(track: Track): HotCue[] {
57
+
58
+ // Read the buffer of an audio file
59
+ const buffer = fs.readFileSync(track.path.toString())
60
+
61
+ // Now, pass it to MP3Tag
62
+ const mp3tag = new MP3Tag(buffer, true)
63
+
64
+ mp3tag.read()
65
+
66
+ const geob = mp3tag.tags.v2?.GEOB
67
+
68
+ if (!geob) {
69
+ return [];
70
+ }
71
+
72
+ const data = Buffer.from(geob[0].object);
73
+ return Array.from(this._decode(data));
74
+ }
75
+
76
+ private *_decode(data: Buffer): IterableIterator<HotCue> {
77
+ const fp = new BufferReader(data);
78
+
79
+ // Expect version 0x01, 0x01
80
+ const a = fp.readUInt8();
81
+ const b = fp.readUInt8();
82
+ if (a !== 0x01 || b !== 0x01) {
83
+ throw new Error("Invalid Serato markers header");
84
+ }
85
+
86
+ const payload = fp.read(data.length - 2);
87
+ let processed = this._removeNullPadding(payload);
88
+ processed = this._padEncodedData(processed);
89
+
90
+ const decoded = Buffer.from(processed.toString(), "base64");
91
+ const fp2 = new BufferReader(decoded);
92
+
93
+ const a2 = fp2.readUInt8();
94
+ const b2 = fp2.readUInt8();
95
+ if (a2 !== 0x01 || b2 !== 0x01) {
96
+ throw new Error("Invalid decoded header");
97
+ }
98
+
99
+ while (true) {
100
+ const entryName = this._getEntryName(fp2);
101
+ if (entryName.length === 0) {
102
+ break;
103
+ }
104
+
105
+ const structLength = fp2.readUInt32BE();
106
+ if (structLength <= 0) throw new Error("Invalid struct length");
107
+
108
+ const entryData = fp2.read(structLength);
109
+
110
+ switch (entryName) {
111
+ case "COLOR":
112
+ // not yet implemented
113
+ continue;
114
+ case "CUE":
115
+ yield HotCue.fromBytes(entryData, HotCueType.CUE);
116
+ continue
117
+ case "LOOP":
118
+ yield HotCue.fromBytes(entryData, HotCueType.LOOP);
119
+ continue
120
+ case "BPMLOCK":
121
+ // not yet implemented
122
+ continue;
123
+ }
124
+ }
125
+ }
126
+
127
+ private _removeNullPadding(payload: Buffer): Buffer {
128
+ const nullIndex = payload.indexOf(0x00);
129
+ return nullIndex >= 0 ? payload.slice(0, nullIndex) : payload;
130
+ }
131
+
132
+ private _getEntryName(fp: BufferReader): string {
133
+ const bytes: number[] = [];
134
+ while (true) {
135
+ const byte = fp.read(1)[0];
136
+ if (byte === 0x00) break;
137
+ bytes.push(byte);
138
+ }
139
+ return Buffer.from(bytes).toString("utf-8");
140
+ }
141
+
142
+ private _padEncodedData(data: Buffer): Buffer {
143
+ const len = data.length;
144
+ let padding: Buffer;
145
+ if (len % 4 === 1) {
146
+ padding = Buffer.from("A==");
147
+ } else {
148
+ padding = Buffer.from("=".repeat((-len % 4 + 4) % 4));
149
+ }
150
+ return Buffer.concat([data, padding]);
151
+ }
152
+
153
+ private _write(track: Track, payload: Buffer): void {
154
+ // Read the buffer of an audio file
155
+ const buffer = fs.readFileSync(track.path.toString())
156
+
157
+ const mp3tag = new MP3Tag(buffer, true)
158
+
159
+ mp3tag.read()
160
+
161
+ // Write the ID3v2 tags.
162
+ // See https://mp3tag.js.org/docs/frames.html for the list of supported ID3v2 frames
163
+
164
+ const object = Array.from(payload)
165
+ mp3tag.tags.v2!.GEOB = [
166
+ {
167
+ format: 'application/octet-stream',
168
+ filename: "",
169
+ object: object,
170
+ description: this.markersName,
171
+ }
172
+ ]
173
+
174
+ // Save the tags
175
+ mp3tag.save({id3v2: {encoding: "latin1"}})
176
+
177
+ // Handle error if there's any
178
+ if (mp3tag.error !== '') throw new Error(mp3tag.error)
179
+
180
+ // Read the new buffer again
181
+ mp3tag.read()
182
+
183
+ // Write the new buffer to file
184
+ fs.writeFileSync(track.path.toString(), mp3tag.buffer)
185
+ }
186
+
187
+ private _encode(track: Track): Buffer {
188
+ let payload = Buffer.alloc(0);
189
+ for (const cue of track.hotCues) {
190
+ payload = Buffer.concat([payload, cue.toV2Bytes()]);
191
+ }
192
+ for (const cue of track.cueLoops) {
193
+ payload = Buffer.concat([payload, cue.toV2Bytes()]);
194
+ }
195
+ return this._pad(payload);
196
+ }
197
+
198
+ private _pad(payload: Buffer, entriesCount?: number): Buffer {
199
+ // prepend tag version
200
+ payload = Buffer.concat([this.tagVersion, payload]);
201
+
202
+ payload = this._removeEncodedDataPad(Buffer.from(payload.toString("base64")));
203
+ payload = this._padPayload(splitString(payload));
204
+ payload = this._enrichPayload(payload, entriesCount);
205
+
206
+ return payload;
207
+ }
208
+
209
+ private _removeEncodedDataPad(data: Buffer): Buffer {
210
+ return Buffer.from(data.toString().replace(/=/g, "A"));
211
+ }
212
+
213
+ private _padPayload(payload: Buffer): Buffer {
214
+ const length = payload.length;
215
+ if (length < 468) {
216
+ return Buffer.concat([payload, Buffer.alloc(468 - length)]);
217
+ }
218
+ return Buffer.concat([payload, Buffer.alloc(982 - length), Buffer.from([0x00])]);
219
+ }
220
+
221
+ private _enrichPayload(payload: Buffer, entriesCount?: number): Buffer {
222
+ let header = this.tagVersion;
223
+ if (entriesCount !== undefined) {
224
+ const buf = Buffer.alloc(4);
225
+ buf.writeUInt32BE(entriesCount, 0);
226
+ header = Buffer.concat([header, buf]);
227
+ }
228
+ return Buffer.concat([header, payload]);
229
+ }
230
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { Builder, DEFAULT_SERATO_FOLDER } from './builder';
2
+ export { V2Mp3Encoder } from './encoders/v2/v2Mp3Encoder';
3
+ export { Crate } from './model/crate';
4
+ export { Track } from './model/track';
5
+ export { HotCue } from './model/hotCue';
6
+ export { HotCueType } from './model/hotCueType';
@@ -0,0 +1,55 @@
1
+ import { Track } from './track';
2
+ import { sanitizeFilename, DuplicateTrackError } from '../util';
3
+
4
+ export class Crate {
5
+ private _children: Map<string, Crate>;
6
+ readonly name: string;
7
+ private _tracks: Set<Track>;
8
+
9
+ constructor(name: string, children?: Map<string, Crate>) {
10
+ this._children = children ?? new Map();
11
+ this.name = sanitizeFilename(name);
12
+ this._tracks = new Set();
13
+ }
14
+
15
+ get children(): Map<string, Crate> {
16
+ return this._children;
17
+ }
18
+
19
+ get tracks(): Set<Track> {
20
+ return this._tracks;
21
+ }
22
+
23
+ addTrack(track: Track): void {
24
+ if (this._tracks.has(track)) {
25
+ throw new DuplicateTrackError(`track ${track} is already in the crate ${this.name}`);
26
+ }
27
+ this._tracks.add(track);
28
+ }
29
+
30
+ toString(): string {
31
+ return `Crate<${this.name}>`;
32
+ }
33
+
34
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
35
+ return this.toString();
36
+ }
37
+
38
+ equals(other: Crate): boolean {
39
+ if (this.name !== other.name) return false;
40
+ if (this._tracks.size !== other._tracks.size) return false;
41
+
42
+ for (const [name, child] of this.children) {
43
+ const otherChild = other.children.get(name);
44
+ if (!otherChild) {
45
+ return false;
46
+ }
47
+ if (!child.equals(otherChild)) {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ return true;
53
+
54
+ }
55
+ }