UNPKG

dcent-beats

Version:

Decentralised music based on Hypercore

201 lines (165 loc) 5.38 kB
const path = require('path') const ReadyResource = require('ready-resource') const { parseBuffer: parseMetadata, selectCover } = require('music-metadata') const Ajv = require('ajv') const errors = require('./lib/errors') // We allow additional properties, to accomodate future minors, // but remove them during validation const ajv = new Ajv({ removeAdditional: 'all' }) const MP3_LOC = '/audio/audio.mp3' const COVER_LOC = '/images/cover' // Adds extension when saving const METADATA_VERSION = { major: 1, minor: 1 } const COVER_MAJOR = 1 const COVER_MINOR = 1 const COVER_FORMATS = ['image/jpeg'] const MAX_COVER_BYTES = 1024 * 1024 const MAX_STRING_LENGTH = 9999 // Just a sanity check const METADATA_SCHEMA = { type: 'object', required: ['version', 'title', 'artist', 'year', 'durationSec'], properties: { version: { type: 'object', required: ['major', 'minor'], additionalProperties: false, properties: { major: { type: 'integer', minimum: 1 }, minor: { type: 'integer', minimum: 1 } } }, title: { type: 'string', maxLength: MAX_STRING_LENGTH }, artist: { type: 'string', maxLength: MAX_STRING_LENGTH }, year: { type: 'integer' }, durationSec: { type: 'number', minimum: 0 } } } const validateMetadataJson = ajv.compile(METADATA_SCHEMA) // Note: could also be initialised lazily class DcentBeats extends ReadyResource { constructor (drive, location) { super() if (!location) throw new Error('Location required') this.drive = drive this.location = location } static MP3_LOC = MP3_LOC static COVER_LOC = COVER_LOC static METADATA_VERSION = METADATA_VERSION static COVER_MAJOR = COVER_MAJOR static COVER_MINOR = COVER_MINOR static MAX_COVER_BYTES = MAX_COVER_BYTES static COVER_FORMATS = COVER_FORMATS get key () { return this.drive.key } get blobsKey () { return this.drive.blobsKey } get mp3Loc () { return path.join(this.location, MP3_LOC) } get coverLoc () { return path.join(this.location, COVER_LOC) } async _open () { await this.drive.ready() } async _close () { await this.drive.close() } async get () { if (!this.opened) await this.ready() return this.drive.get(this.mp3Loc) } // Not race condition safe (the user is responsible for // not putting into the same location simultaneously) async put (blob) { if (!this.opened) await this.ready() if (await this.drive.get(this.mp3Loc) !== null) { throw errors.LOCATION_NOT_EMPTY(this.mp3Loc) } const { format, common } = await parseMetadata(blob) const metadata = { version: METADATA_VERSION, title: common.title, artist: common.artist, year: common.year, durationSec: format.duration } this.constructor.validateMetadata(metadata) let cover = null const parsedCover = selectCover(common.picture) if (parsedCover) { cover = { metadata: { format: parsedCover.format, major: COVER_MAJOR, minor: COVER_MINOR }, data: parsedCover.data } this.constructor.validateCover(cover) } // Note: batches only protect the db, but blobs // can still be written for aborted transactions // (hyperdrive limitation) // We should move to hypercore atoms once Hyperdrive supports those const batch = this.drive.batch() const proms = [batch.put(this.mp3Loc, blob, { metadata })] if (cover) { proms.push(batch.put( this.coverLoc, cover.data, { metadata: cover.metadata } )) } try { await Promise.all(proms) await batch.flush() } finally { await batch.close() } } async getCover () { if (!this.opened) await this.ready() const [data, entry] = await Promise.all([ this.drive.get(this.coverLoc), this.drive.entry(this.coverLoc) ]) const metadata = entry?.value?.metadata if (data === null || !metadata) return null this.constructor.validateCover({ data, metadata }) return { data, format: metadata.format, major: metadata.major, minor: metadata.minor } } async exists () { return await this.drive.exists(this.mp3Loc) } async getMetadata () { if (!this.opened) await this.ready() const entry = await this.drive.entry(this.mp3Loc) const metadata = entry?.value?.metadata if (!metadata) throw errors.LOCATION_EMPTY(this.mp3Loc) this.constructor.validateMetadata(metadata) return metadata } createReadStream () { return this.drive.createReadStream(this.mp3Loc) } static validateMetadata (metadata) { if (metadata.version.major !== METADATA_VERSION.major) { throw errors.INCOMPATIBLE_METADATA_MAJOR(metadata.version.major, METADATA_VERSION.major) } const valid = validateMetadataJson(metadata) if (!valid) throw errors.INVALID_METADATA(valid) } static validateCover ({ data, metadata }) { if (metadata.major !== COVER_MAJOR) throw errors.INCOMPATIBLE_COVER_MAJOR(metadata.major, COVER_MAJOR) if (!COVER_FORMATS.includes(metadata.format)) throw errors.UNSUPPORTED_COVER_FORMAT(metadata.format) if (data.byteLength > MAX_COVER_BYTES) throw errors.COVER_TOO_LARGE(data.byteLength, MAX_COVER_BYTES) } } module.exports = DcentBeats