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/.github/workflows/release.yml +53 -0
- package/dist/builder.d.ts +2 -1
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +57 -55
- package/dist/encoders/v2/v2Mp3Encoder.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/model/crate.d.ts +2 -4
- package/dist/model/crate.d.ts.map +1 -1
- package/dist/model/crate.js +7 -37
- package/dist/model/track.d.ts.map +1 -1
- package/dist/model/track.js +3 -1
- package/dist/util.d.ts +1 -1
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +10 -28
- package/eslint.config.js +50 -0
- package/package.json +7 -2
- package/src/builder.ts +198 -0
- package/src/encoders/baseEncoder.ts +8 -0
- package/src/encoders/serato_tags.ts +4 -0
- package/src/encoders/utils.ts +21 -0
- package/src/encoders/v2/v2Mp3Encoder.ts +230 -0
- package/src/index.ts +6 -0
- package/src/model/crate.ts +55 -0
- package/src/model/hotCue.ts +206 -0
- package/src/model/hotCueType.ts +6 -0
- package/src/model/seratoColor.ts +20 -0
- package/src/model/tempo.ts +4 -0
- package/src/model/track.ts +73 -0
- package/src/util.ts +79 -0
- package/tsconfig.json +18 -0
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,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
|
+
}
|