dcent-beats
Version:
Decentralised music based on Hypercore
201 lines (165 loc) • 5.38 kB
JavaScript
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