created deemix submodule, version 3.6.15 with spotify playlist fix. No longer hosted on NPM, but as local submodule

This commit is contained in:
deeplydrumming
2024-07-22 13:11:10 +01:00
parent 4485d6663b
commit 09c393fb3f
28 changed files with 4175 additions and 0 deletions

13
deemix/.eslintrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"env": {
"commonjs": true,
"es2021": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 12
},
"rules": {
}
}

74
deemix/.gitignore vendored Normal file
View File

@@ -0,0 +1,74 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
# IDE
.vscode
.idea
# development
*.map
dev.sh
# distribution
dist/*
yarn.lock

188
deemix/deemix/decryption.js Normal file
View File

@@ -0,0 +1,188 @@
const got = require('got')
const fs = require('fs')
const { _md5, _ecbCrypt, _ecbDecrypt, generateBlowfishKey, decryptChunk } = require('./utils/crypto.js')
const { DownloadCanceled, DownloadEmpty } = require('./errors.js')
const { USER_AGENT_HEADER, pipeline } = require('./utils/index.js')
function generateStreamPath (sngID, md5, mediaVersion, format) {
let urlPart = md5 + '¤' + format + '¤' + sngID + '¤' + mediaVersion
const md5val = _md5(urlPart)
let step2 = md5val + '¤' + urlPart + '¤'
step2 += '.'.repeat(16 - (step2.length % 16))
urlPart = _ecbCrypt('jo6aey6haid2Teih', step2)
return urlPart
}
function reverseStreamPath (urlPart) {
const step2 = _ecbDecrypt('jo6aey6haid2Teih', urlPart)
const [, md5, format, sngID, mediaVersion] = step2.split('¤')
return [sngID, md5, mediaVersion, format]
}
function generateCryptedStreamURL (sngID, md5, mediaVersion, format) {
const urlPart = generateStreamPath(sngID, md5, mediaVersion, format)
return 'https://e-cdns-proxy-' + md5[0] + '.dzcdn.net/mobile/1/' + urlPart
}
function generateStreamURL (sngID, md5, mediaVersion, format) {
const urlPart = generateStreamPath(sngID, md5, mediaVersion, format)
return 'https://cdns-proxy-' + md5[0] + '.dzcdn.net/api/1/' + urlPart
}
function reverseStreamURL (url) {
const urlPart = url.slice(url.find('/1/') + 3)
return reverseStreamPath(urlPart)
}
async function streamTrack (writepath, track, downloadObject, listener) {
if (downloadObject && downloadObject.isCanceled) throw new DownloadCanceled()
const headers = { 'User-Agent': USER_AGENT_HEADER }
let chunkLength = 0
let complete = 0
const isCryptedStream = track.downloadURL.includes('/mobile/') || track.downloadURL.includes('/media/')
let blowfishKey
const outputStream = fs.createWriteStream(writepath)
let timeout = null
const itemData = {
id: track.id,
title: track.title,
artist: track.mainArtist.name
}
let error = ''
if (isCryptedStream) blowfishKey = generateBlowfishKey(String(track.id))
async function * decrypter (source) {
let modifiedStream = Buffer.alloc(0)
for await (const chunk of source) {
if (!isCryptedStream) {
yield chunk
} else {
modifiedStream = Buffer.concat([modifiedStream, chunk])
while (modifiedStream.length >= 2048 * 3) {
let decryptedChunks = Buffer.alloc(0)
const decryptingChunks = modifiedStream.slice(0, 2048 * 3)
modifiedStream = modifiedStream.slice(2048 * 3)
if (decryptingChunks.length >= 2048) {
decryptedChunks = decryptChunk(decryptingChunks.slice(0, 2048), blowfishKey)
decryptedChunks = Buffer.concat([decryptedChunks, decryptingChunks.slice(2048)])
}
yield decryptedChunks
}
}
}
if (isCryptedStream) {
let decryptedChunks = Buffer.alloc(0)
if (modifiedStream.length >= 2048) {
decryptedChunks = decryptChunk(modifiedStream.slice(0, 2048), blowfishKey)
decryptedChunks = Buffer.concat([decryptedChunks, modifiedStream.slice(2048)])
yield decryptedChunks
} else {
yield modifiedStream
}
}
}
async function * depadder (source) {
let isStart = true
for await (let chunk of source) {
if (isStart && chunk[0] === 0 && chunk.slice(4, 8).toString() !== 'ftyp') {
let i
for (i = 0; i < chunk.length; i++) {
const byte = chunk[i]
if (byte !== 0) break
}
chunk = chunk.slice(i)
}
isStart = false
yield chunk
}
}
const request = got.stream(track.downloadURL, {
headers,
https: { rejectUnauthorized: false }
}).on('response', (response) => {
clearTimeout(timeout)
complete = parseInt(response.headers['content-length'])
if (complete === 0) {
error = 'DownloadEmpty'
request.destroy()
}
if (listener) {
listener.send('downloadInfo', {
uuid: downloadObject.uuid,
data: itemData,
state: 'downloading',
alreadyStarted: false,
value: complete
})
}
}).on('data', function (chunk) {
if (downloadObject.isCanceled) {
error = 'DownloadCanceled'
request.destroy()
}
chunkLength += chunk.length
if (downloadObject) {
downloadObject.progressNext += ((chunk.length / complete) / downloadObject.size) * 100
downloadObject.updateProgress(listener)
}
clearTimeout(timeout)
timeout = setTimeout(() => {
error = 'DownloadTimeout'
request.destroy()
}, 5000)
})
timeout = setTimeout(() => {
error = 'DownloadTimeout'
request.destroy()
}, 5000)
try {
await pipeline(request, decrypter, depadder, outputStream)
} catch (e) {
if (fs.existsSync(writepath)) fs.unlinkSync(writepath)
if (
e instanceof got.ReadError ||
e instanceof got.TimeoutError ||
['ESOCKETTIMEDOUT', 'ERR_STREAM_PREMATURE_CLOSE', 'ETIMEDOUT', 'ECONNRESET'].includes(e.code) ||
(request.destroyed && error === 'DownloadTimeout')
) {
if (downloadObject && chunkLength !== 0) {
downloadObject.progressNext -= ((chunkLength / complete) / downloadObject.size) * 100
downloadObject.updateProgress(listener)
}
if (listener) {
listener.send('downloadInfo', {
uuid: downloadObject.uuid,
data: itemData,
state: 'downloadTimeout'
})
}
return await streamTrack(writepath, track, downloadObject, listener)
} else if (request.destroyed) {
switch (error) {
case 'DownloadEmpty': throw new DownloadEmpty()
case 'DownloadCanceled': throw new DownloadCanceled()
default: throw e
}
} else {
console.trace(e)
throw e
}
}
}
module.exports = {
generateStreamPath,
generateStreamURL,
generateCryptedStreamURL,
reverseStreamPath,
reverseStreamURL,
streamTrack
}

837
deemix/deemix/downloader.js Normal file
View File

@@ -0,0 +1,837 @@
const { Track } = require('./types/Track.js')
const { StaticPicture } = require('./types/Picture.js')
const { streamTrack, generateCryptedStreamURL } = require('./decryption.js')
const { tagID3, tagFLAC } = require('./tagger.js')
const { USER_AGENT_HEADER, pipeline, shellEscape } = require('./utils/index.js')
const { DEFAULTS, OverwriteOption } = require('./settings.js')
const { generatePath, generateAlbumName, generateArtistName, generateDownloadObjectName } = require('./utils/pathtemplates.js')
const { PreferredBitrateNotFound, TrackNot360, DownloadFailed, ErrorMessages, DownloadCanceled } = require('./errors.js')
const { TrackFormats } = require('deezer-js')
const { WrongLicense, WrongGeolocation } = require('deezer-js').errors
const { map_track } = require('deezer-js').utils
const got = require('got')
const fs = require('fs')
const { tmpdir } = require('os')
const { queue, each } = require('async')
const { exec } = require('child_process')
const extensions = {
[TrackFormats.FLAC]: '.flac',
[TrackFormats.LOCAL]: '.mp3',
[TrackFormats.MP3_320]: '.mp3',
[TrackFormats.MP3_128]: '.mp3',
[TrackFormats.DEFAULT]: '.mp3',
[TrackFormats.MP4_RA3]: '.mp4',
[TrackFormats.MP4_RA2]: '.mp4',
[TrackFormats.MP4_RA1]: '.mp4'
}
const formatsName = {
[TrackFormats.FLAC]: 'FLAC',
[TrackFormats.LOCAL]: 'MP3_MISC',
[TrackFormats.MP3_320]: 'MP3_320',
[TrackFormats.MP3_128]: 'MP3_128',
[TrackFormats.DEFAULT]: 'MP3_MISC',
[TrackFormats.MP4_RA3]: 'MP4_RA3',
[TrackFormats.MP4_RA2]: 'MP4_RA2',
[TrackFormats.MP4_RA1]: 'MP4_RA1'
}
const TEMPDIR = tmpdir() + '/deemix-imgs'
fs.mkdirSync(TEMPDIR, { recursive: true })
async function downloadImage (url, path, overwrite = OverwriteOption.DONT_OVERWRITE) {
if (fs.existsSync(path) && ![OverwriteOption.OVERWRITE, OverwriteOption.ONLY_TAGS, OverwriteOption.KEEP_BOTH].includes(overwrite)) {
const file = fs.readFileSync(path)
if (file.length !== 0) return path
fs.unlinkSync(path)
}
let timeout = null
let error = ''
const downloadStream = got.stream(url, { headers: { 'User-Agent': USER_AGENT_HEADER }, https: { rejectUnauthorized: false } })
.on('data', function () {
clearTimeout(timeout)
timeout = setTimeout(() => {
error = 'DownloadTimeout'
downloadStream.destroy()
}, 5000)
})
const fileWriterStream = fs.createWriteStream(path)
timeout = setTimeout(() => {
error = 'DownloadTimeout'
downloadStream.destroy()
}, 5000)
try {
await pipeline(downloadStream, fileWriterStream)
} catch (e) {
fs.unlinkSync(path)
if (e instanceof got.HTTPError) {
if (url.includes('images.dzcdn.net')) {
const urlBase = url.slice(0, url.lastIndexOf('/') + 1)
const pictureURL = url.slice(urlBase.length)
const pictureSize = parseInt(pictureURL.slice(0, pictureURL.indexOf('x')))
if (pictureSize > 1200) { return downloadImage(urlBase + pictureURL.replace(`${pictureSize}x${pictureSize}`, '1200x1200'), path, overwrite) }
}
return null
}
if (
e instanceof got.ReadError ||
e instanceof got.TimeoutError ||
['ESOCKETTIMEDOUT', 'ERR_STREAM_PREMATURE_CLOSE', 'ETIMEDOUT', 'ECONNRESET'].includes(e.code) ||
(downloadStream.destroyed && error === 'DownloadTimeout')
) {
return downloadImage(url, path, overwrite)
}
console.trace(e)
throw e
}
return path
}
async function getPreferredBitrate (dz, track, preferredBitrate, shouldFallback, feelingLucky, uuid, listener) {
preferredBitrate = parseInt(preferredBitrate)
let falledBack = false
let hasAlternative = track.fallbackID !== 0
let isGeolocked = false
let wrongLicense = false
async function testURL (track, url, formatName) {
if (!url) return false
let request
try {
request = got.get(
url,
{ headers: { 'User-Agent': USER_AGENT_HEADER }, https: { rejectUnauthorized: false }, timeout: 7000 }
).on('response', (response) => {
track.filesizes[`${formatName.toLowerCase()}`] = response.statusCode === 403 ? 0 : response.headers['content-length']
request.cancel()
})
await request
} catch (e) {
if (e.isCanceled) {
if (track.filesizes[`${formatName.toLowerCase()}`] === 0) return false
return true
}
if (e instanceof got.ReadError || e instanceof got.TimeoutError) {
return await testURL(track, url, formatName)
}
if (e instanceof got.HTTPError) return false
console.trace(e)
throw e
}
}
async function getCorrectURL (track, formatName, formatNumber, feelingLucky) {
// Check the track with the legit method
let url
wrongLicense = (
((formatName === 'FLAC' || formatName.startsWith('MP4_RA')) && !dz.current_user.can_stream_lossless) ||
(formatName === 'MP3_320' && !dz.current_user.can_stream_hq)
)
if (track.filesizes[`${formatName.toLowerCase()}`] && track.filesizes[`${formatName.toLowerCase()}`] !== '0') {
try {
url = await dz.get_track_url(track.trackToken, formatName)
} catch (e) {
wrongLicense = (e.name === 'WrongLicense')
isGeolocked = (e.name === 'WrongGeolocation')
}
}
// Fallback to old method
if (!url && feelingLucky) {
url = generateCryptedStreamURL(track.id, track.MD5, track.mediaVersion, formatNumber)
if (await testURL(track, url, formatName, formatNumber)) return url
url = undefined
}
return url
}
if (track.local) {
const url = await getCorrectURL(track, 'MP3_MISC', TrackFormats.LOCAL, feelingLucky)
track.urls.MP3_MISC = url
return TrackFormats.LOCAL
}
const formats_non_360 = {
[TrackFormats.FLAC]: 'FLAC',
[TrackFormats.MP3_320]: 'MP3_320',
[TrackFormats.MP3_128]: 'MP3_128'
}
const formats_360 = {
[TrackFormats.MP4_RA3]: 'MP4_RA3',
[TrackFormats.MP4_RA2]: 'MP4_RA2',
[TrackFormats.MP4_RA1]: 'MP4_RA1'
}
const is360Format = Object.keys(formats_360).includes(preferredBitrate)
let formats
if (!shouldFallback) {
formats = { ...formats_360, ...formats_non_360 }
} else if (is360Format) {
formats = { ...formats_360 }
} else {
formats = { ...formats_non_360 }
}
// Check and renew trackToken before starting the check
await track.checkAndRenewTrackToken(dz)
for (let i = 0; i < Object.keys(formats).length; i++) {
// Check bitrates
const formatNumber = Object.keys(formats).reverse()[i]
const formatName = formats[formatNumber]
// Current bitrate is higher than preferred bitrate; skip
if (formatNumber > preferredBitrate) { continue }
let currentTrack = track
let url = await getCorrectURL(currentTrack, formatName, formatNumber, feelingLucky)
let newTrack
do {
if (!url && hasAlternative) {
newTrack = await dz.gw.get_track_with_fallback(currentTrack.fallbackID)
newTrack = map_track(newTrack)
currentTrack = new Track()
currentTrack.parseEssentialData(newTrack)
hasAlternative = currentTrack.fallbackID !== 0
}
if (!url) url = await getCorrectURL(currentTrack, formatName, formatNumber, feelingLucky)
} while (!url && hasAlternative)
if (url) {
if (newTrack) track.parseEssentialData(newTrack)
track.urls[formatName] = url
return formatNumber
}
if (!shouldFallback) {
if (wrongLicense) throw new WrongLicense(formatName)
if (isGeolocked) throw new WrongGeolocation(dz.current_user.country)
throw new PreferredBitrateNotFound()
} else if (!falledBack) {
falledBack = true
if (listener && uuid) {
listener.send('downloadInfo', {
uuid,
state: 'bitrateFallback',
data: {
id: track.id,
title: track.title,
artist: track.mainArtist.name
}
})
}
}
}
if (is360Format) throw new TrackNot360()
const url = await getCorrectURL(track, 'MP3_MISC', TrackFormats.DEFAULT, feelingLucky)
track.urls.MP3_MISC = url
return TrackFormats.DEFAULT
}
class Downloader {
constructor (dz, downloadObject, settings, listener) {
this.dz = dz
this.downloadObject = downloadObject
this.settings = settings || DEFAULTS
this.bitrate = downloadObject.bitrate
this.listener = listener
this.playlistCovername = null
this.playlistURLs = []
this.coverQueue = {}
}
log (data, state) {
if (this.listener) { this.listener.send('downloadInfo', { uuid: this.downloadObject.uuid, data, state }) }
}
warn (data, state, solution) {
if (this.listener) { this.listener.send('downloadWarn', { uuid: this.downloadObject.uuid, data, state, solution }) }
}
async start () {
if (!this.downloadObject.isCanceled) {
if (this.downloadObject.__type__ === 'Single') {
const track = await this.downloadWrapper({
trackAPI: this.downloadObject.single.trackAPI,
albumAPI: this.downloadObject.single.albumAPI
})
if (track) await this.afterDownloadSingle(track)
} else if (this.downloadObject.__type__ === 'Collection') {
const tracks = []
const q = queue(async (data) => {
const { track, pos } = data
tracks[pos] = await this.downloadWrapper({
trackAPI: track,
albumAPI: this.downloadObject.collection.albumAPI,
playlistAPI: this.downloadObject.collection.playlistAPI
})
}, this.settings.queueConcurrency)
if (this.downloadObject.collection.tracks.length) {
this.downloadObject.collection.tracks.forEach((track, pos) => {
q.push({ track, pos })
})
await q.drain()
}
await this.afterDownloadCollection(tracks)
}
}
if (this.listener) {
if (this.downloadObject.isCanceled) {
this.listener.send('currentItemCancelled', this.downloadObject.uuid)
this.listener.send('removedFromQueue', this.downloadObject.uuid)
} else {
this.listener.send('finishDownload', this.downloadObject.uuid)
}
}
}
async download (extraData, track) {
const returnData = {}
const { trackAPI, albumAPI, playlistAPI } = extraData
trackAPI.size = this.downloadObject.size
if (this.downloadObject.isCanceled) throw new DownloadCanceled()
if (parseInt(trackAPI.id) === 0) throw new DownloadFailed('notOnDeezer')
let itemData = {
id: trackAPI.id,
title: trackAPI.title,
artist: trackAPI.artist.name
}
// Generate track object
if (!track) {
track = new Track()
this.log(itemData, 'getTags')
try {
await track.parseData(
this.dz,
trackAPI.id,
trackAPI,
albumAPI,
playlistAPI
)
} catch (e) {
if (e.name === 'AlbumDoesntExists') { throw new DownloadFailed('albumDoesntExists') }
if (e.name === 'MD5NotFound') { throw new DownloadFailed('notLoggedIn') }
console.trace(e)
throw e
}
this.log(itemData, 'gotTags')
}
if (this.downloadObject.isCanceled) throw new DownloadCanceled()
itemData = {
id: track.id,
title: track.title,
artist: track.mainArtist.name
}
// Check if the track is encoded
if (track.MD5 === '') throw new DownloadFailed('notEncoded', track)
// Check the target bitrate
this.log(itemData, 'getBitrate')
let selectedFormat
try {
selectedFormat = await getPreferredBitrate(
this.dz,
track,
this.bitrate,
this.settings.fallbackBitrate, this.settings.feelingLucky,
this.downloadObject.uuid, this.listener
)
} catch (e) {
if (e.name === 'WrongLicense') { throw new DownloadFailed('wrongLicense') }
if (e.name === 'WrongGeolocation') { throw new DownloadFailed('wrongGeolocation', track) }
if (e.name === 'PreferredBitrateNotFound') { throw new DownloadFailed('wrongBitrate', track) }
if (e.name === 'TrackNot360') { throw new DownloadFailed('no360RA') }
console.trace(e)
throw e
}
track.bitrate = selectedFormat
track.album.bitrate = selectedFormat
this.log(itemData, 'gotBitrate')
// Apply Settings
track.applySettings(this.settings)
// Generate filename and filepath from metadata
const {
filename,
filepath,
artistPath,
coverPath,
extrasPath
} = generatePath(track, this.downloadObject, this.settings)
if (this.downloadObject.isCanceled) throw new DownloadCanceled()
// Make sure the filepath exsists
fs.mkdirSync(filepath, { recursive: true })
const extension = extensions[track.bitrate]
let writepath = `${filepath}/${filename}${extension}`
// Save extrasPath
if (extrasPath && !this.downloadObject.extrasPath) {
this.downloadObject.extrasPath = extrasPath
}
// Generate covers URLs
let embeddedImageFormat = `jpg-${this.settings.jpegImageQuality}`
if (this.settings.embeddedArtworkPNG) embeddedImageFormat = 'png'
track.album.embeddedCoverURL = track.album.pic.getURL(this.settings.embeddedArtworkSize, embeddedImageFormat)
let ext = track.album.embeddedCoverURL.slice(-4)
if (ext.charAt(0) !== '.') ext = '.jpg'
track.album.embeddedCoverPath = `${TEMPDIR}/${track.album.isPlaylist ? 'pl' + track.playlist.id : 'alb' + track.album.id}_${this.settings.embeddedArtworkSize}${ext}`
// Download and cache the coverart
this.log(itemData, 'getAlbumArt')
if (!this.coverQueue[track.album.embeddedCoverPath]) { this.coverQueue[track.album.embeddedCoverPath] = downloadImage(track.album.embeddedCoverURL, track.album.embeddedCoverPath) }
track.album.embeddedCoverPath = await this.coverQueue[track.album.embeddedCoverPath]
if (this.coverQueue[track.album.embeddedCoverPath]) delete this.coverQueue[track.album.embeddedCoverPath]
this.log(itemData, 'gotAlbumArt')
// Save local album art
if (coverPath) {
returnData.albumURLs = []
this.settings.localArtworkFormat.split(',').forEach((picFormat) => {
if (['png', 'jpg'].includes(picFormat)) {
let extendedFormat = picFormat
if (extendedFormat === 'jpg') extendedFormat += `-${this.settings.jpegImageQuality}`
const url = track.album.pic.getURL(this.settings.localArtworkSize, extendedFormat)
// Skip non deezer pictures at the wrong format
if (track.album.pic instanceof StaticPicture && picFormat !== 'jpg') return
returnData.albumURLs.push({ url, ext: picFormat })
}
})
returnData.albumPath = coverPath
returnData.albumFilename = generateAlbumName(this.settings.coverImageTemplate, track.album, this.settings, track.playlist)
}
// Save artist art
if (artistPath) {
returnData.artistURLs = []
this.settings.localArtworkFormat.split(',').forEach((picFormat) => {
// Deezer doesn't support png artist images
if (picFormat === 'jpg') {
const extendedFormat = `${picFormat}-${this.settings.jpegImageQuality}`
const url = track.album.mainArtist.pic.getURL(this.settings.localArtworkSize, extendedFormat)
// Skip non deezer pictures at the wrong format
if (track.album.mainArtist.pic.md5 === '') return
returnData.artistURLs.push({ url, ext: picFormat })
}
})
returnData.artistPath = artistPath
returnData.artistFilename = generateArtistName(this.settings.artistImageTemplate, track.album.mainArtist, this.settings, track.album.rootArtist)
}
// Save playlist art
if (track.playlist) {
if (this.playlistURLs.length === 0) {
this.settings.localArtworkFormat.split(',').forEach((picFormat) => {
if (['png', 'jpg'].includes(picFormat)) {
let extendedFormat = picFormat
if (extendedFormat === 'jpg') extendedFormat += `-${this.settings.jpegImageQuality}`
const url = track.playlist.pic.getURL(this.settings.localArtworkSize, extendedFormat)
// Skip non deezer pictures at the wrong format
if (track.playlist.pic instanceof StaticPicture && picFormat !== 'jpg') return
this.playlistURLs.push({ url, ext: picFormat })
}
})
}
if (!this.playlistCovername) {
track.playlist.bitrate = track.bitrate
track.playlist.dateString = track.playlist.date.format(this.settings.dateFormat)
this.playlistCovername = generateAlbumName(this.settings.coverImageTemplate, track.playlist, this.settings, track.playlist)
}
}
// Save lyrics in lrc file
if (this.settings.syncedLyrics && track.lyrics.sync) {
if (!fs.existsSync(`${filepath}/${filename}.lrc`) || [OverwriteOption.OVERWRITE, OverwriteOption.ONLY_TAGS].includes(this.settings.overwriteFile)) { fs.writeFileSync(`${filepath}/${filename}.lrc`, track.lyrics.sync) }
}
// Check for overwrite settings
let trackAlreadyDownloaded = fs.existsSync(writepath)
// Don't overwrite and don't mind extension
if (!trackAlreadyDownloaded && this.settings.overwriteFile === OverwriteOption.DONT_CHECK_EXT) {
const extensions = ['.mp3', '.flac', '.opus', '.m4a']
const baseFilename = `${filepath}/${filename}`
for (let i = 0; i < extensions.length; i++) {
const ext = extensions[i]
trackAlreadyDownloaded = fs.existsSync(baseFilename + ext)
if (trackAlreadyDownloaded) break
}
}
// Overwrite only lower bitrates
if (trackAlreadyDownloaded && this.settings.overwriteFile === OverwriteOption.ONLY_LOWER_BITRATES && extension === '.mp3') {
const stats = fs.statSync(writepath)
const fileSizeKb = stats.size * 8 / 1024
const bitrateAprox = fileSizeKb / track.duration
if (selectedFormat !== 0 && bitrateAprox < 310 && selectedFormat === 3) { trackAlreadyDownloaded = false }
}
// Don't overwrite and keep both files
if (trackAlreadyDownloaded && this.settings.overwriteFile === OverwriteOption.KEEP_BOTH) {
const baseFilename = `${filepath}/${filename}`
let currentFilename
let c = 0
do {
c++
currentFilename = `${baseFilename} (${c})${extension}`
} while (fs.existsSync(currentFilename))
trackAlreadyDownloaded = false
writepath = currentFilename
}
// Download the track
if (!trackAlreadyDownloaded || this.settings.overwriteFile === OverwriteOption.OVERWRITE) {
track.downloadURL = track.urls[formatsName[track.bitrate]]
if (!track.downloadURL) throw new DownloadFailed('notAvailable', track)
try {
await streamTrack(writepath, track, this.downloadObject, this.listener)
} catch (e) {
if (e instanceof got.HTTPError) throw new DownloadFailed('notAvailable', track)
throw e
}
this.log(itemData, 'downloaded')
} else {
this.log(itemData, 'alreadyDownloaded')
this.downloadObject.completeTrackProgress(this.listener)
}
// Adding tags
if (!trackAlreadyDownloaded || ([OverwriteOption.ONLY_TAGS, OverwriteOption.OVERWRITE].includes(this.settings.overwriteFile) && !track.local)) {
this.log(itemData, 'tagging')
if (extension === '.mp3') {
tagID3(writepath, track, this.settings.tags)
} else if (extension === '.flac') {
tagFLAC(writepath, track, this.settings.tags)
}
this.log(itemData, 'tagged')
}
if (track.searched) returnData.searched = true
this.downloadObject.downloaded += 1
if (this.listener) {
this.listener.send('updateQueue', {
uuid: this.downloadObject.uuid,
downloaded: true,
downloadPath: String(writepath),
extrasPath: String(this.downloadObject.extrasPath)
})
}
returnData.filename = writepath.slice(extrasPath.length + 1)
returnData.data = itemData
returnData.path = String(writepath)
this.downloadObject.files.push(returnData)
return returnData
}
async downloadWrapper (extraData, track) {
const { trackAPI } = extraData
// Temp metadata to generate logs
const itemData = {
id: trackAPI.id,
title: trackAPI.title,
artist: trackAPI.artist.name
}
let result
try {
result = await this.download(extraData, track)
} catch (e) {
if (e instanceof DownloadFailed) {
if (e.track) {
const track = e.track
if (track.fallbackID !== 0) {
this.warn(itemData, e.errid, 'fallback')
let newTrack = await this.dz.gw.get_track_with_fallback(track.fallbackID)
newTrack = map_track(newTrack)
track.parseEssentialData(newTrack)
return await this.downloadWrapper(extraData, track)
}
if (track.albumsFallback.length && this.settings.fallbackISRC) {
const newAlbumID = track.albumsFallback.pop()
const newAlbum = await this.dz.gw.get_album_page(newAlbumID)
let fallbackID = 0
for (const newTrack of newAlbum.SONGS.data) {
if (newTrack.ISRC === track.ISRC) {
fallbackID = newTrack.SNG_ID
break
}
}
if (fallbackID !== 0) {
this.warn(itemData, e.errid, 'fallback')
let newTrack = await this.dz.gw.get_track_with_fallback(fallbackID)
newTrack = map_track(newTrack)
track.parseEssentialData(newTrack)
return await this.downloadWrapper(extraData, track)
}
}
if (!track.searched && this.settings.fallbackSearch) {
this.warn(itemData, e.errid, 'search')
const searchedID = await this.dz.api.get_track_id_from_metadata(track.mainArtist.name, track.title, track.album.title)
if (searchedID !== '0') {
let newTrack = await this.dz.gw.get_track_with_fallback(searchedID)
newTrack = map_track(newTrack)
track.parseEssentialData(newTrack)
track.searched = true
this.log(itemData, 'searchFallback')
return await this.downloadWrapper(extraData, track)
}
}
e.errid += 'NoAlternative'
e.message = ErrorMessages[e.errid]
}
result = {
error: {
message: e.message,
errid: e.errid,
data: itemData,
type: 'track'
}
}
} else if (e instanceof DownloadCanceled) {
return
} else {
console.trace(e)
result = {
error: {
message: e.message,
data: itemData,
stack: String(e.stack),
type: 'track'
}
}
}
}
if (result.error) {
this.downloadObject.completeTrackProgress(this.listener)
this.downloadObject.failed += 1
this.downloadObject.errors.push(result.error)
if (this.listener) {
const error = result.error
this.listener.send('updateQueue', {
uuid: this.downloadObject.uuid,
failed: true,
data: error.data,
error: error.message,
errid: error.errid || null,
stack: error.stack || null,
type: error.type
})
}
}
return result
}
afterDownloadErrorReport (position, error, itemData = {}) {
console.trace(error)
this.downloadObject.errors.push({
message: error.message,
stack: String(error.stack),
data: { position, ...itemData },
type: 'post'
})
if (this.listener) {
this.listener.send('updateQueue', {
uuid: this.downloadObject.uuid,
postFailed: true,
error: error.message,
data: { position, ...itemData },
stack: error.stack,
type: 'post'
})
}
}
async afterDownloadSingle (track) {
if (!track) return
if (!this.downloadObject.extrasPath) {
this.downloadObject.extrasPath = this.settings.downloadLocation
}
// Save local album artwork
try {
if (this.settings.saveArtwork && track.albumPath) {
await each(track.albumURLs, async (image) => {
await downloadImage(image.url, `${track.albumPath}/${track.albumFilename}.${image.ext}`, this.settings.overwriteFile)
})
}
} catch (e) {
this.afterDownloadErrorReport('SaveLocalAlbumArt', e)
}
// Save local artist artwork
try {
if (this.settings.saveArtworkArtist && track.artistPath) {
await each(track.artistURLs, async (image) => {
await downloadImage(image.url, `${track.artistPath}/${track.artistFilename}.${image.ext}`, this.settings.overwriteFile)
})
}
} catch (e) {
this.afterDownloadErrorReport('SaveLocalArtistArt', e)
}
// Create searched logfile
try {
if (this.settings.logSearched && track.searched) {
const filename = `${track.data.artist} - ${track.data.title}`
let searchedFile
try {
searchedFile = fs.readFileSync(`${this.downloadObject.extrasPath}/searched.txt`).toString()
} catch { searchedFile = '' }
if (searchedFile.indexOf(filename) === -1) {
if (searchedFile !== '') searchedFile += '\r\n'
searchedFile += filename + '\r\n'
fs.writeFileSync(`${this.downloadObject.extrasPath}/searched.txt`, searchedFile)
}
}
} catch (e) {
this.afterDownloadErrorReport('CreateSearchedLog', e)
}
// Execute command after download
try {
if (this.settings.executeCommand !== '') {
const child = exec(this.settings.executeCommand.replaceAll('%folder%', shellEscape(this.downloadObject.extrasPath)).replaceAll('%filename%', shellEscape(track.filename)),
(error, stdout, stderr) => {
if (error) this.afterDownloadErrorReport('ExecuteCommand', error)
const itemData = { stderr, stdout }
if (stderr) this.log(itemData, 'stderr')
if (stdout) this.log(itemData, 'stdout')
}
)
await new Promise((resolve) => {
child.on('close', resolve)
})
}
} catch (e) {
this.afterDownloadErrorReport('ExecuteCommand', e)
}
}
async afterDownloadCollection (tracks) {
if (!this.downloadObject.extrasPath) {
this.downloadObject.extrasPath = this.settings.downloadLocation
}
const playlist = []
let errors = ''
let searched = ''
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i]
if (!track) return
if (track.error) {
if (!track.error.data) track.error.data = { id: '0', title: 'Unknown', artist: 'Unknown' }
errors += `${track.error.data.id} | ${track.error.data.artist} - ${track.error.data.title} | ${track.error.message}\r\n`
}
if (track.searched) searched += `${track.data.artist} - ${track.data.title}\r\n`
// Save local album artwork
try {
if (this.settings.saveArtwork && track.albumPath) {
await each(track.albumURLs, async (image) => {
await downloadImage(image.url, `${track.albumPath}/${track.albumFilename}.${image.ext}`, this.settings.overwriteFile)
})
}
} catch (e) {
this.afterDownloadErrorReport('SaveLocalAlbumArt', e, track.data)
}
// Save local artist artwork
try {
if (this.settings.saveArtworkArtist && track.artistPath) {
await each(track.artistURLs, async (image) => {
await downloadImage(image.url, `${track.artistPath}/${track.artistFilename}.${image.ext}`, this.settings.overwriteFile)
})
}
} catch (e) {
this.afterDownloadErrorReport('SaveLocalArtistArt', e, track.data)
}
// Save filename for playlist file
playlist[i] = track.filename || ''
}
// Create errors logfile
try {
if (this.settings.logErrors && errors !== '') { fs.writeFileSync(`${this.downloadObject.extrasPath}/errors.txt`, errors) }
} catch (e) {
this.afterDownloadErrorReport('CreateErrorLog', e)
}
// Create searched logfile
try {
if (this.settings.logSearched && searched !== '') { fs.writeFileSync(`${this.downloadObject.extrasPath}/searched.txt`, searched) }
} catch (e) {
this.afterDownloadErrorReport('CreateSearchedLog', e)
}
// Save Playlist Artwork
try {
if (this.settings.saveArtwork && this.playlistCovername && !this.settings.tags.savePlaylistAsCompilation) {
await each(this.playlistURLs, async (image) => {
await downloadImage(image.url, `${this.downloadObject.extrasPath}/${this.playlistCovername}.${image.ext}`, this.settings.overwriteFile)
})
}
} catch (e) {
this.afterDownloadErrorReport('SavePlaylistArt', e)
}
// Create M3U8 File
try {
if (this.settings.createM3U8File) {
const filename = generateDownloadObjectName(this.settings.playlistFilenameTemplate, this.downloadObject, this.settings) || 'playlist'
fs.writeFileSync(`${this.downloadObject.extrasPath}/${filename}.m3u8`, playlist.join('\n'))
}
} catch (e) {
this.afterDownloadErrorReport('CreatePlaylistFile', e)
}
// Execute command after download
try {
if (this.settings.executeCommand !== '') {
const child = exec(this.settings.executeCommand.replaceAll('%folder%', shellEscape(this.downloadObject.extrasPath)).replaceAll('%filename%', ''),
(error, stdout, stderr) => {
if (error) this.afterDownloadErrorReport('ExecuteCommand', error)
const itemData = { stderr, stdout }
if (stderr) this.log(itemData, 'stderr')
if (stdout) this.log(itemData, 'stdout')
}
)
await new Promise((resolve) => {
child.on('close', resolve)
})
}
} catch (e) {
this.afterDownloadErrorReport('ExecuteCommand', e)
}
}
}
module.exports = {
Downloader,
downloadImage,
getPreferredBitrate
}

183
deemix/deemix/errors.js Normal file
View File

@@ -0,0 +1,183 @@
class DeemixError extends Error {
constructor (message) {
super(message)
this.name = 'DeemixError'
}
}
class GenerationError extends DeemixError {
constructor (link, message) {
super(message)
this.link = link
this.name = 'GenerationError'
}
}
class ISRCnotOnDeezer extends GenerationError {
constructor (link) {
super(link, 'Track ISRC is not available on deezer')
this.name = 'ISRCnotOnDeezer'
this.errid = 'ISRCnotOnDeezer'
}
}
class NotYourPrivatePlaylist extends GenerationError {
constructor (link) {
super(link, "You can't download others private playlists.")
this.name = 'NotYourPrivatePlaylist'
this.errid = 'notYourPrivatePlaylist'
}
}
class TrackNotOnDeezer extends GenerationError {
constructor (link) {
super(link, 'Track not found on deezer!')
this.name = 'TrackNotOnDeezer'
this.errid = 'trackNotOnDeezer'
}
}
class AlbumNotOnDeezer extends GenerationError {
constructor (link) {
super(link, 'Album not found on deezer!')
this.name = 'AlbumNotOnDeezer'
this.errid = 'albumNotOnDeezer'
}
}
class InvalidID extends GenerationError {
constructor (link) {
super(link, 'Link ID is invalid!')
this.name = 'InvalidID'
this.errid = 'invalidID'
}
}
class LinkNotSupported extends GenerationError {
constructor (link) {
super(link, 'Link is not supported.')
this.name = 'LinkNotSupported'
this.errid = 'unsupportedURL'
}
}
class LinkNotRecognized extends GenerationError {
constructor (link) {
super(link, 'Link is not recognized.')
this.name = 'LinkNotRecognized'
this.errid = 'invalidURL'
}
}
class DownloadError extends DeemixError {
constructor () {
super()
this.name = 'DownloadError'
}
}
const ErrorMessages = {
notOnDeezer: 'Track not available on Deezer!',
notEncoded: 'Track not yet encoded!',
notEncodedNoAlternative: 'Track not yet encoded and no alternative found!',
wrongBitrate: 'Track not found at desired bitrate.',
wrongBitrateNoAlternative: 'Track not found at desired bitrate and no alternative found!',
wrongLicense: "Your account can't stream the track at the desired bitrate.",
no360RA: 'Track is not available in Reality Audio 360.',
notAvailable: "Track not available on deezer's servers!",
notAvailableNoAlternative: "Track not available on deezer's servers and no alternative found!",
noSpaceLeft: 'No space left on target drive, clean up some space for the tracks.',
albumDoesntExists: "Track's album does not exsist, failed to gather info.",
notLoggedIn: 'You need to login to download tracks.',
wrongGeolocation: "Your account can't stream the track from your current country.",
wrongGeolocationNoAlternative: "Your account can't stream the track from your current country and no alternative found."
}
class DownloadFailed extends DownloadError {
constructor (errid, track) {
super()
this.errid = errid
this.message = ErrorMessages[errid]
this.name = 'DownloadFailed'
this.track = track
}
}
class TrackNot360 extends DownloadError {
constructor () {
super()
this.name = 'TrackNot360'
}
}
class PreferredBitrateNotFound extends DownloadError {
constructor () {
super()
this.name = 'PreferredBitrateNotFound'
}
}
class DownloadEmpty extends DeemixError {
constructor () {
super()
this.name = 'DownloadEmpty'
}
}
class DownloadCanceled extends DeemixError {
constructor () {
super()
this.name = 'DownloadCanceled'
}
}
class TrackError extends DeemixError {
constructor (message) {
super(message)
this.name = 'TrackError'
}
}
class MD5NotFound extends TrackError {
constructor (message) {
super(message)
this.name = 'MD5NotFound'
}
}
class NoDataToParse extends TrackError {
constructor (message) {
super(message)
this.name = 'NoDataToParse'
}
}
class AlbumDoesntExists extends TrackError {
constructor (message) {
super(message)
this.name = 'AlbumDoesntExists'
}
}
module.exports = {
DeemixError,
GenerationError,
ISRCnotOnDeezer,
NotYourPrivatePlaylist,
TrackNotOnDeezer,
AlbumNotOnDeezer,
InvalidID,
LinkNotSupported,
LinkNotRecognized,
ErrorMessages,
DownloadError,
DownloadFailed,
TrackNot360,
PreferredBitrateNotFound,
DownloadEmpty,
DownloadCanceled,
TrackError,
MD5NotFound,
NoDataToParse,
AlbumDoesntExists
}

116
deemix/deemix/index.js Normal file
View File

@@ -0,0 +1,116 @@
const got = require('got')
const {
generateTrackItem,
generateAlbumItem,
generatePlaylistItem,
generateArtistItem,
generateArtistTopItem
} = require('./itemgen.js')
const {
LinkNotSupported,
LinkNotRecognized
} = require('./errors.js')
async function parseLink (link) {
if (link.includes('deezer.page.link')) {
link = await got.get(link, { https: { rejectUnauthorized: false } }) // Resolve URL shortner
link = link.url
}
// Remove extra stuff
if (link.includes('?')) link = link.slice(0, link.indexOf('?'))
if (link.includes('&')) link = link.slice(0, link.indexOf('&'))
if (link.endsWith('/')) link = link.slice(0, -1) // Remove last slash if present
let link_type, link_id
let link_data
if (!link.includes('deezer')) return [link, link_type, link_id] // return if not a deezer link
if (link.search(/\/track\/(.+)/g) !== -1) {
link_type = 'track'
link_id = /\/track\/(.+)/g.exec(link)[1]
} else if (link.search(/\/playlist\/(\d+)/g) !== -1) {
link_type = 'playlist'
link_id = /\/playlist\/(\d+)/g.exec(link)[1]
} else if (link.search(/\/album\/(.+)/g) !== -1) {
link_type = 'album'
link_id = /\/album\/(.+)/g.exec(link)[1]
} else if (link.search(/\/artist\/(\d+)\/top_track/g) !== -1) {
link_type = 'artist_top'
link_id = /\/artist\/(\d+)\/top_track/g.exec(link)[1]
} else if (link.search(/\/artist\/(\d+)\/(.+)/g) !== -1) {
link_data = /\/artist\/(\d+)\/(.+)/g.exec(link)
link_type = `artist_${link_data[2]}`
link_id = link_data[1]
} else if (link.search(/\/artist\/(\d+)/g) !== -1) {
link_type = 'artist'
link_id = /\/artist\/(\d+)/g.exec(link)[1]
}
return [link, link_type, link_id]
}
async function generateDownloadObject (dz, link, bitrate, plugins = {}, listener) {
let link_type, link_id
[link, link_type, link_id] = await parseLink(link)
if (link_type == null || link_id == null) {
const pluginNames = Object.keys(plugins)
let currentPlugin
let item = null
for (let i = 0; i < pluginNames.length; i++) {
currentPlugin = plugins[pluginNames[i]]
item = await currentPlugin.generateDownloadObject(dz, link, bitrate, listener)
if (item) break
}
if (item) return item
throw new LinkNotRecognized(link)
}
if (link_type === 'track') return generateTrackItem(dz, link_id, bitrate)
if (link_type === 'album') return generateAlbumItem(dz, link_id, bitrate)
if (link_type === 'playlist') return generatePlaylistItem(dz, link_id, bitrate)
if (link_type === 'artist') return generateArtistItem(dz, link_id, bitrate, listener, 'all')
if (link_type === 'artist_top') return generateArtistTopItem(dz, link_id, bitrate)
if (link_type.startsWith('artist_')) {
const tab = link_type.slice(7)
return generateArtistItem(dz, link_id, bitrate, listener, tab)
}
throw new LinkNotSupported(link)
}
module.exports = {
parseLink,
generateDownloadObject,
types: {
...require('./types/index.js'),
...require('./types/Album.js'),
...require('./types/Artist.js'),
...require('./types/Date.js'),
...require('./types/Lyrics.js'),
...require('./types/Picture.js'),
...require('./types/Playlist.js'),
...require('./types/Track.js'),
downloadObjects: require('./types/DownloadObjects.js')
},
itemgen: {
generateTrackItem,
generateAlbumItem,
generatePlaylistItem,
generateArtistItem,
generateArtistTopItem
},
settings: require('./settings.js'),
downloader: require('./downloader.js'),
decryption: require('./decryption.js'),
tagger: require('./tagger.js'),
utils: {
...require('./utils/index.js'),
localpaths: require('./utils/localpaths.js'),
pathtemplates: require('./utils/pathtemplates.js'),
deezer: require('./utils/deezer.js')
},
plugins: {
spotify: require('./plugins/spotify.js')
}
}

304
deemix/deemix/itemgen.js Normal file
View File

@@ -0,0 +1,304 @@
const {
Single,
Collection
} = require('./types/DownloadObjects.js')
const { GenerationError, ISRCnotOnDeezer, InvalidID, NotYourPrivatePlaylist } = require('./errors.js')
const { map_user_playlist, map_track, map_album } = require('deezer-js').utils
const { each } = require('async')
async function generateTrackItem (dz, id, bitrate, trackAPI, albumAPI) {
// Get essential track info
if (!trackAPI) {
if (String(id).startsWith('isrc') || parseInt(id) > 0) {
try {
trackAPI = await dz.api.get_track(id)
} catch (e) {
console.trace(e)
throw new GenerationError(`https://deezer.com/track/${id}`, e.message)
}
// Check if is an isrc: url
if (String(id).startsWith('isrc')) {
if (trackAPI.id && trackAPI.title) id = trackAPI.id
else throw new ISRCnotOnDeezer(`https://deezer.com/track/${id}`)
}
} else {
const trackAPI_gw = await dz.gw.get_track(id)
trackAPI = map_track(trackAPI_gw)
}
} else {
id = trackAPI.id
}
if (!(/^-?\d+$/.test(id))) throw new InvalidID(`https://deezer.com/track/${id}`)
let cover
if (trackAPI.album.cover_small) {
cover = trackAPI.album.cover_small.slice(0, -24) + '/75x75-000000-80-0-0.jpg'
} else {
cover = `https://e-cdns-images.dzcdn.net/images/cover/${trackAPI.md5_image}/75x75-000000-80-0-0.jpg`
}
delete trackAPI.track_token
return new Single({
type: 'track',
id,
bitrate,
title: trackAPI.title,
artist: trackAPI.artist.name,
cover,
explicit: trackAPI.explicit_lyrics,
single: {
trackAPI,
albumAPI
}
})
}
async function generateAlbumItem (dz, id, bitrate, rootArtist) {
// Get essential album info
let albumAPI
if (String(id).startsWith('upc')) {
const upcs = [id.slice(4)]
upcs.push(parseInt(upcs[0])) // Try UPC without leading zeros as well
let lastError
await each(upcs, async (upc) => {
try {
albumAPI = await dz.api.get_album(`upc:${upc}`)
} catch (e) {
lastError = e
albumAPI = null
}
})
if (!albumAPI) {
console.trace(lastError)
throw new GenerationError(`https://deezer.com/album/${id}`, lastError.message)
}
id = albumAPI.id
} else {
try {
const albumAPI_gw_page = await dz.gw.get_album_page(id)
if (albumAPI_gw_page.DATA) {
albumAPI = map_album(albumAPI_gw_page.DATA)
id = albumAPI_gw_page.DATA.ALB_ID
const albumAPI_new = await dz.api.get_album(id)
albumAPI = { ...albumAPI, ...albumAPI_new }
} else {
throw new GenerationError(`https://deezer.com/album/${id}`, "Can't find the album")
}
} catch (e) {
console.trace(e)
throw new GenerationError(`https://deezer.com/album/${id}`, e.message)
}
}
if (!(/^\d+$/.test(id))) throw new InvalidID(`https://deezer.com/album/${id}`)
// Get extra info about album
// This saves extra api calls when downloading
let albumAPI_gw = await dz.gw.get_album(id)
albumAPI_gw = map_album(albumAPI_gw)
albumAPI = { ...albumAPI_gw, ...albumAPI }
albumAPI.root_artist = rootArtist
// If the album is a single download as a track
if (albumAPI.nb_tracks === 1) {
if (albumAPI.tracks.data.length) { return generateTrackItem(dz, albumAPI.tracks.data[0].id, bitrate, null, albumAPI) }
throw new GenerationError(`https://deezer.com/album/${id}`, 'Single has no tracks.')
}
const tracksArray = await dz.gw.get_album_tracks(id)
let cover
if (albumAPI.cover_small) {
cover = albumAPI.cover_small.slice(0, -24) + '/75x75-000000-80-0-0.jpg'
} else {
cover = `https://e-cdns-images.dzcdn.net/images/cover/${albumAPI.md5_image}/75x75-000000-80-0-0.jpg`
}
const totalSize = tracksArray.length
albumAPI.nb_tracks = totalSize
const collection = []
tracksArray.forEach((trackAPI, pos) => {
trackAPI = map_track(trackAPI)
delete trackAPI.track_token
trackAPI.position = pos + 1
collection.push(trackAPI)
})
return new Collection({
type: 'album',
id,
bitrate,
title: albumAPI.title,
artist: albumAPI.artist.name,
cover,
explicit: albumAPI.explicit_lyrics,
size: totalSize,
collection: {
tracks: collection,
albumAPI
}
})
}
async function generatePlaylistItem (dz, id, bitrate, playlistAPI, playlistTracksAPI) {
if (!playlistAPI) {
if (!(/^\d+$/.test(id))) throw new InvalidID(`https://deezer.com/playlist/${id}`)
// Get essential playlist info
try {
playlistAPI = await dz.api.get_playlist(id)
} catch (e) {
console.trace(e)
playlistAPI = null
}
// Fallback to gw api if the playlist is private
if (!playlistAPI) {
try {
const userPlaylist = await dz.gw.get_playlist_page(id)
playlistAPI = map_user_playlist(userPlaylist.DATA)
} catch (e) {
console.trace(e)
throw new GenerationError(`https://deezer.com/playlist/${id}`, e.message)
}
}
// Check if private playlist and owner
if (!playlistAPI.public && playlistAPI.creator.id !== dz.current_user.id) {
throw new NotYourPrivatePlaylist(`https://deezer.com/playlist/${id}`)
}
}
if (!playlistTracksAPI) {
playlistTracksAPI = await dz.gw.get_playlist_tracks(id)
}
playlistAPI.various_artist = await dz.api.get_artist(5080) // Useful for save as compilation
const totalSize = playlistTracksAPI.length
playlistAPI.nb_tracks = totalSize
const collection = []
playlistTracksAPI.forEach((trackAPI, pos) => {
trackAPI = map_track(trackAPI)
if (trackAPI.explicit_lyrics) { playlistAPI.explicit = true }
delete trackAPI.track_token
trackAPI.position = pos + 1
collection.push(trackAPI)
})
if (!playlistAPI.explicit) playlistAPI.explicit = false
return new Collection({
type: 'playlist',
id,
bitrate,
title: playlistAPI.title,
artist: playlistAPI.creator.name,
cover: playlistAPI.picture_small.slice(0, -24) + '/75x75-000000-80-0-0.jpg',
explicit: playlistAPI.explicit,
size: totalSize,
collection: {
tracks: collection,
playlistAPI
}
})
}
async function generateArtistItem (dz, id, bitrate, listener, tab = 'all') {
let path = ''
if (tab !== 'all') path = '/' + tab
if (!(/^\d+$/.test(id))) throw new InvalidID(`https://deezer.com/artist/${id}${path}`)
// Get essential artist info
let artistAPI
try {
artistAPI = await dz.api.get_artist(id)
} catch (e) {
console.trace(e)
throw new GenerationError(`https://deezer.com/artist/${id}${path}`, e.message)
}
const rootArtist = {
id: artistAPI.id,
name: artistAPI.name,
picture_small: artistAPI.picture_small
}
if (listener) { listener.send('startAddingArtist', rootArtist) }
const artistDiscographyAPI = await dz.gw.get_artist_discography_tabs(id, 100)
const albumList = []
if (tab === 'discography') {
delete artistDiscographyAPI.all
await each(artistDiscographyAPI, async (type) => {
await each(type, async (album) => {
try {
const albumData = await generateAlbumItem(dz, album.id, bitrate, rootArtist)
albumList.push(albumData)
} catch (e) {
console.warn(album.id, 'No Data', e)
}
})
})
} else {
const tabReleases = artistDiscographyAPI[tab] || []
await each(tabReleases, async (album) => {
try {
const albumData = await generateAlbumItem(dz, album.id, bitrate, rootArtist)
albumList.push(albumData)
} catch (e) {
console.warn(album.id, 'No Data', e)
}
})
}
if (listener) { listener.send('finishAddingArtist', rootArtist) }
return albumList
}
async function generateArtistTopItem (dz, id, bitrate) {
if (!(/^\d+$/.test(id))) throw new InvalidID(`https://deezer.com/artist/${id}/top_track`)
// Get essential artist info
let artistAPI
try {
artistAPI = await dz.api.get_artist(id)
} catch (e) {
console.trace(e)
throw new GenerationError(`https://deezer.com/artist/${id}/top_track`, e.message)
}
// Emulate the creation of a playlist
// Can't use generatePlaylistItem directly as this is not a real playlist
const playlistAPI = {
id: artistAPI.id + '_top_track',
title: artistAPI.name + ' - Top Tracks',
description: 'Top Tracks for ' + artistAPI.name,
duration: 0,
public: true,
is_loved_track: false,
collaborative: false,
nb_tracks: 0,
fans: artistAPI.nb_fan,
link: 'https://www.deezer.com/artist/' + artistAPI.id + '/top_track',
share: null,
picture: artistAPI.picture,
picture_small: artistAPI.picture_small,
picture_medium: artistAPI.picture_medium,
picture_big: artistAPI.picture_big,
picture_xl: artistAPI.picture_xl,
checksum: null,
tracklist: 'https://api.deezer.com/artist/' + artistAPI.id + '/top',
creation_date: 'XXXX-00-00',
creator: {
id: 'art_' + artistAPI.id,
name: artistAPI.name,
type: 'user'
},
type: 'playlist'
}
const artistTopTracksAPI_gw = await dz.gw.get_artist_top_tracks(id)
return generatePlaylistItem(dz, playlistAPI.id, bitrate, playlistAPI, artistTopTracksAPI_gw)
}
module.exports = {
generateTrackItem,
generateAlbumItem,
generatePlaylistItem,
generateArtistItem,
generateArtistTopItem
}

View File

@@ -0,0 +1,15 @@
class Plugin {
/* constructor () {} */
async setup () {}
async parseLink (link) {
return [link, undefined, undefined]
}
/* eslint no-unused-vars: ["error", { "args": "none" }] */
async generateDownloadObject (dz, link, bitrate, listener) {
return null
}
}
module.exports = Plugin

View File

@@ -0,0 +1,455 @@
const Plugin = require('./index.js')
const { getConfigFolder } = require('../utils/localpaths.js')
const {
generateTrackItem,
generateAlbumItem,
TrackNotOnDeezer,
AlbumNotOnDeezer,
InvalidID
} = require('../itemgen.js')
const { Convertable, Collection } = require('../types/DownloadObjects.js')
const { sep } = require('path')
const fs = require('fs')
const SpotifyWebApi = require('spotify-web-api-node')
const got = require('got')
const { queue } = require('async')
class Spotify extends Plugin {
constructor (configFolder = undefined) {
super()
this.credentials = { clientId: '', clientSecret: '' }
this.settings = {
fallbackSearch: false
}
this.enabled = false
/* this.sp */
this.configFolder = configFolder || getConfigFolder()
this.configFolder += `spotify${sep}`
return this
}
setup () {
fs.mkdirSync(this.configFolder, { recursive: true })
this.loadSettings()
return this
}
async parseLink (link) {
if (link.includes('link.tospotify.com')) {
link = await got.get(link, { https: { rejectUnauthorized: false } }) // Resolve URL shortner
link = link.url
}
// Remove extra stuff
if (link.includes('?')) link = link.slice(0, link.indexOf('?'))
if (link.includes('&')) link = link.slice(0, link.indexOf('&'))
if (link.endsWith('/')) link = link.slice(0, -1) // Remove last slash if present
let link_type, link_id
if (!link.includes('spotify')) return [link, link_type, link_id] // return if not a spotify link
if (link.search(/[/:]track[/:](.+)/g) !== -1) {
link_type = 'track'
link_id = /[/:]track[/:](.+)/g.exec(link)[1]
} else if (link.search(/[/:]album[/:](.+)/g) !== -1) {
link_type = 'album'
link_id = /[/:]album[/:](.+)/g.exec(link)[1]
} else if (link.search(/[/:]playlist[/:](\d+)/g) !== -1) {
link_type = 'playlist'
link_id = /[/:]playlist[/:](.+)/g.exec(link)[1]
}
return [link, link_type, link_id]
}
async generateDownloadObject (dz, link, bitrate) {
let link_type, link_id
[link, link_type, link_id] = await this.parseLink(link)
if (link_type == null || link_id == null) return null
switch (link_type) {
case 'track':
return this.generateTrackItem(dz, link_id, bitrate)
case 'album':
return this.generateAlbumItem(dz, link_id, bitrate)
case 'playlist':
return this.generatePlaylistItem(dz, link_id, bitrate)
}
}
async generateTrackItem (dz, link_id, bitrate) {
const cache = this.loadCache()
let cachedTrack
if (cache.tracks[link_id]) {
cachedTrack = cache.tracks[link_id]
} else {
cachedTrack = await this.getTrack(link_id)
cache.tracks[link_id] = cachedTrack
this.saveCache(cache)
}
if (cachedTrack.isrc) {
try { return generateTrackItem(dz, `isrc:${cachedTrack.isrc}`, bitrate) } catch (e) { /* empty */ }
}
if (this.settings.fallbackSearch) {
if (!cachedTrack.id || cachedTrack.id === '0') {
const trackID = await dz.api.get_track_id_from_metadata(
cachedTrack.data.artist,
cachedTrack.data.title,
cachedTrack.data.album
)
if (trackID !== '0') {
cachedTrack.id = trackID
cache.tracks[link_id] = cachedTrack
this.saveCache(cache)
}
}
if (cachedTrack.id !== '0') return generateTrackItem(dz, cachedTrack.id, bitrate)
}
throw new TrackNotOnDeezer(`https://open.spotify.com/track/${link_id}`)
}
async generateAlbumItem (dz, link_id, bitrate) {
const cache = this.loadCache()
let cachedAlbum
if (cache.albums[link_id]) {
cachedAlbum = cache.albums[link_id]
} else {
cachedAlbum = await this.getAlbum(link_id)
cache.albums[link_id] = cachedAlbum
this.saveCache(cache)
}
try {
return generateAlbumItem(dz, `upc:${cachedAlbum.upc}`, bitrate)
} catch (e) {
throw new AlbumNotOnDeezer(`https://open.spotify.com/album/${link_id}`)
}
}
async generatePlaylistItem (dz, link_id, bitrate) {
if (!this.enabled) throw new Error('Spotify plugin not enabled')
let spotifyPlaylist = await this.sp.getPlaylist(link_id)
spotifyPlaylist = spotifyPlaylist.body
const playlistAPI = this._convertPlaylistStructure(spotifyPlaylist)
playlistAPI.various_artist = await dz.api.get_artist(5080) // Useful for save as compilation
let tracklistTemp = spotifyPlaylist.tracks.items
while (spotifyPlaylist.tracks.next) {
const regExec = /offset=(\d+)&limit=(\d+)/g.exec(spotifyPlaylist.tracks.next)
const offset = regExec[1]
const limit = regExec[2]
const playlistTracks = await this.sp.getPlaylistTracks(link_id, { offset, limit })
spotifyPlaylist.tracks = playlistTracks.body
tracklistTemp = tracklistTemp.concat(spotifyPlaylist.tracks.items)
}
const tracklist = []
tracklistTemp.forEach((item) => {
if (!item.track) return // Skip everything that isn't a track
if (item.track.explicit && !playlistAPI.explicit) playlistAPI.explicit = true
tracklist.push(item.track)
})
if (!playlistAPI.explicit) playlistAPI.explicit = false
return new Convertable({
type: 'spotify_playlist',
id: link_id,
bitrate,
title: spotifyPlaylist.name,
artist: spotifyPlaylist.owner.display_name,
cover: playlistAPI.picture_thumbnail,
explicit: playlistAPI.explicit,
size: tracklist.length,
collection: {
tracks: [],
playlistAPI
},
plugin: 'spotify',
conversion_data: tracklist
})
}
async getTrack (track_id, spotifyTrack = null) {
if (!this.enabled) throw new Error('Spotify plugin not enabled')
const cachedTrack = {
isrc: null,
data: null
}
if (!spotifyTrack) {
try {
spotifyTrack = await this.sp.getTrack(track_id)
} catch (e) {
if (e.body.error.message === 'invalid id') throw new InvalidID(`https://open.spotify.com/track/${track_id}`)
throw e
}
spotifyTrack = spotifyTrack.body
}
if (spotifyTrack.external_ids && spotifyTrack.external_ids.isrc) cachedTrack.isrc = spotifyTrack.external_ids.isrc
cachedTrack.data = {
title: spotifyTrack.name,
artist: spotifyTrack.artists[0].name,
album: spotifyTrack.album.name
}
return cachedTrack
}
async getAlbum (album_id, spotifyAlbum = null) {
if (!this.enabled) throw new Error('Spotify plugin not enabled')
const cachedAlbum = {
upc: null,
data: null
}
if (!spotifyAlbum) {
try {
spotifyAlbum = await this.sp.getAlbum(album_id)
} catch (e) {
if (e.body.error.message === 'invalid id') throw new InvalidID(`https://open.spotify.com/album/${album_id}`)
throw e
}
spotifyAlbum = spotifyAlbum.body
}
if (spotifyAlbum.external_ids && spotifyAlbum.external_ids.upc) cachedAlbum.upc = spotifyAlbum.external_ids.upc
cachedAlbum.data = {
title: spotifyAlbum.name,
artist: spotifyAlbum.artists[0].name
}
return cachedAlbum
}
async convert (dz, downloadObject, settings, listener = null) {
const cache = this.loadCache()
let conversion = 0
let conversionNext = 0
const collection = []
if (listener) listener.send('startConversion', downloadObject.uuid)
const q = queue(async (data) => {
const { track, pos } = data
if (downloadObject.isCanceled) return
let cachedTrack, trackAPI
if (cache.tracks[track.id]) {
cachedTrack = cache.tracks[track.id]
} else {
cachedTrack = await this.getTrack(track.id, track)
cache.tracks[track.id] = cachedTrack
this.saveCache(cache)
}
if (cachedTrack.isrc) {
try {
trackAPI = await dz.api.get_track_by_ISRC(cachedTrack.isrc)
if (!trackAPI.id || !trackAPI.title) trackAPI = null
} catch { /* Empty */ }
}
if (this.settings.fallbackSearch && !trackAPI) {
if (!cachedTrack.id || cachedTrack.id === '0') {
const trackID = await dz.api.get_track_id_from_metadata(
cachedTrack.data.artist,
cachedTrack.data.title,
cachedTrack.data.album
)
if (trackID !== '0') {
cachedTrack.id = trackID
cache.tracks[track.id] = cachedTrack
this.saveCache(cache)
}
}
if (cachedTrack.id !== '0') trackAPI = await dz.api.get_track(cachedTrack.id)
}
if (!trackAPI) {
trackAPI = {
id: '0',
title: track.name,
duration: 0,
md5_origin: 0,
media_version: 0,
filesizes: {},
album: {
title: track.album.name,
md5_image: ''
},
artist: {
id: 0,
name: track.artists[0].name,
md5_image: ''
}
}
}
trackAPI.position = pos + 1
collection[pos] = trackAPI
conversionNext += (1 / downloadObject.size) * 100
if (Math.round(conversionNext) !== conversion && Math.round(conversionNext) % 2 === 0) {
conversion = Math.round(conversionNext)
if (listener) listener.send('updateQueue', { uuid: downloadObject.uuid, conversion })
}
}, settings.queueConcurrency)
downloadObject.conversion_data.forEach((track, pos) => {
q.push({ track, pos })
})
await q.drain()
downloadObject.collection.tracks = collection
downloadObject.size = collection.length
downloadObject = new Collection(downloadObject.toDict())
if (listener) listener.send('finishConversion', downloadObject.getSlimmedDict())
fs.writeFileSync(this.configFolder + 'cache.json', JSON.stringify(cache))
return downloadObject
}
_convertPlaylistStructure (spotifyPlaylist) {
let cover = null
if (spotifyPlaylist.images.length) cover = spotifyPlaylist.images[0].url
const deezerPlaylist = {
checksum: spotifyPlaylist.snapshot_id,
collaborative: spotifyPlaylist.collaborative,
creation_date: 'XXXX-00-00',
creator: {
id: spotifyPlaylist.owner.id,
name: spotifyPlaylist.owner.display_name,
tracklist: spotifyPlaylist.owner.href,
type: 'user'
},
description: spotifyPlaylist.description,
duration: 0,
fans: spotifyPlaylist.followers ? spotifyPlaylist.followers.total : 0,
id: spotifyPlaylist.id,
is_loved_track: false,
link: spotifyPlaylist.external_urls.spotify,
nb_tracks: spotifyPlaylist.tracks.total,
picture: cover,
picture_small: cover || 'https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/56x56-000000-80-0-0.jpg',
picture_medium: cover || 'https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/250x250-000000-80-0-0.jpg',
picture_big: cover || 'https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/500x500-000000-80-0-0.jpg',
picture_xl: cover || 'https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg',
picture_thumbnail: cover || 'https://e-cdns-images.dzcdn.net/images/cover/d41d8cd98f00b204e9800998ecf8427e/75x75-000000-80-0-0.jpg',
public: spotifyPlaylist.public,
share: spotifyPlaylist.external_urls.spotify,
title: spotifyPlaylist.name,
tracklist: spotifyPlaylist.tracks.href,
type: 'playlist'
}
return deezerPlaylist
}
loadSettings () {
if (!fs.existsSync(this.configFolder + 'config.json')) {
fs.writeFileSync(this.configFolder + 'config.json', JSON.stringify({
...this.credentials,
...this.settings
}, null, 2))
}
let settings
try {
settings = JSON.parse(fs.readFileSync(this.configFolder + 'config.json'))
} catch (e) {
if (e.name === 'SyntaxError') {
fs.writeFileSync(this.configFolder + 'config.json', JSON.stringify({
...this.credentials,
...this.settings
}, null, 2))
}
settings = JSON.parse(JSON.stringify({
...this.credentials,
...this.settings
}))
}
this.setSettings(settings)
this.checkCredentials()
}
saveSettings (newSettings) {
if (newSettings) this.setSettings(newSettings)
this.checkCredentials()
fs.writeFileSync(this.configFolder + 'config.json', JSON.stringify({
...this.credentials,
...this.settings
}, null, 2))
}
getSettings () {
return {
...this.credentials,
...this.settings
}
}
setSettings (newSettings) {
this.credentials = { clientId: newSettings.clientId, clientSecret: newSettings.clientSecret }
const settings = { ...newSettings }
delete settings.clientId
delete settings.clientSecret
this.settings = settings
}
loadCache () {
let cache
try {
cache = JSON.parse(fs.readFileSync(this.configFolder + 'cache.json'))
} catch (e) {
if (e.name === 'SyntaxError') {
fs.writeFileSync(this.configFolder + 'cache.json', JSON.stringify(
{ tracks: {}, albums: {} },
null, 2
))
}
cache = { tracks: {}, albums: {} }
}
return cache
}
saveCache (newCache) {
fs.writeFileSync(this.configFolder + 'cache.json', JSON.stringify(newCache))
}
checkCredentials () {
if (this.credentials.clientId === '' || this.credentials.clientSecret === '') {
this.enabled = false
return
}
this.sp = new SpotifyWebApi(this.credentials)
this.sp.clientCredentialsGrant().then(
(creds) => {
this.sp.setAccessToken(creds.body.access_token)
// Need to get a new access_token when it expires
setTimeout(() => {
this.checkCredentials()
}, creds.body.expires_in * 1000 - 10)
this.enabled = true
},
() => {
this.enabled = false
this.sp = undefined
}
)
}
getCredentials () {
return this.credentials
}
setCredentials (clientId, clientSecret) {
clientId = clientId.trim()
clientSecret = clientSecret.trim()
this.credentials = { clientId, clientSecret }
this.saveSettings()
}
}
module.exports = Spotify

175
deemix/deemix/settings.js Normal file
View File

@@ -0,0 +1,175 @@
const { TrackFormats } = require('deezer-js')
const { getMusicFolder, getConfigFolder } = require('./utils/localpaths.js')
const fs = require('fs')
// Should the lib overwrite files?
const OverwriteOption = {
OVERWRITE: 'y', // Yes, overwrite the file
DONT_OVERWRITE: 'n', // No, don't overwrite the file
DONT_CHECK_EXT: 'e', // No, and don't check for extensions
KEEP_BOTH: 'b', // No, and keep both files
ONLY_TAGS: 't', // Overwrite only the tags
ONLY_LOWER_BITRATES: 'l' // Overwrite only lower bitrates
}
// What should I do with featured artists?
const FeaturesOption = {
NO_CHANGE: '0', // Do nothing
REMOVE_TITLE: '1', // Remove from track title
REMOVE_TITLE_ALBUM: '3', // Remove from track title and album title
MOVE_TITLE: '2' // Move to track title
}
const DEFAULTS = {
downloadLocation: getMusicFolder(),
tracknameTemplate: '%artist% - %title%',
albumTracknameTemplate: '%tracknumber% - %title%',
playlistTracknameTemplate: '%position% - %artist% - %title%',
createPlaylistFolder: true,
playlistNameTemplate: '%playlist%',
createArtistFolder: false,
artistNameTemplate: '%artist%',
createAlbumFolder: true,
albumNameTemplate: '%artist% - %album%',
createCDFolder: true,
createStructurePlaylist: false,
createSingleFolder: false,
padTracks: true,
padSingleDigit: true,
paddingSize: '0',
illegalCharacterReplacer: '_',
queueConcurrency: 3,
maxBitrate: String(TrackFormats.MP3_128),
feelingLucky: false,
fallbackBitrate: false,
fallbackSearch: false,
fallbackISRC: false,
logErrors: true,
logSearched: false,
overwriteFile: OverwriteOption.DONT_OVERWRITE,
createM3U8File: false,
playlistFilenameTemplate: 'playlist',
syncedLyrics: false,
embeddedArtworkSize: 800,
embeddedArtworkPNG: false,
localArtworkSize: 1200,
localArtworkFormat: 'jpg',
saveArtwork: true,
coverImageTemplate: 'cover',
saveArtworkArtist: false,
artistImageTemplate: 'folder',
jpegImageQuality: 90,
dateFormat: 'Y-M-D',
albumVariousArtists: true,
removeAlbumVersion: false,
removeDuplicateArtists: true,
featuredToTitle: FeaturesOption.NO_CHANGE,
titleCasing: 'nothing',
artistCasing: 'nothing',
executeCommand: '',
tags: {
title: true,
artist: true,
artists: true,
album: true,
cover: true,
trackNumber: true,
trackTotal: false,
discNumber: true,
discTotal: false,
albumArtist: true,
genre: true,
year: true,
date: true,
explicit: false,
isrc: true,
length: true,
barcode: true,
bpm: true,
replayGain: false,
label: true,
lyrics: false,
syncedLyrics: false,
copyright: false,
composer: false,
involvedPeople: false,
source: false,
rating: false,
savePlaylistAsCompilation: false,
useNullSeparator: false,
saveID3v1: true,
multiArtistSeparator: 'default',
singleAlbumArtist: false,
coverDescriptionUTF8: false
}
}
function save (settings, configFolder) {
configFolder = configFolder || getConfigFolder()
if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder)
fs.writeFileSync(configFolder + 'config.json', JSON.stringify(settings, null, 2))
}
function load (configFolder) {
configFolder = configFolder || getConfigFolder()
if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder)
if (!fs.existsSync(configFolder + 'config.json')) save(DEFAULTS, configFolder)
let settings
try {
settings = JSON.parse(fs.readFileSync(configFolder + 'config.json'))
} catch (e) {
if (e.name === 'SyntaxError') save(DEFAULTS, configFolder)
settings = JSON.parse(JSON.stringify(DEFAULTS))
}
if (check(settings) > 0) save(settings, configFolder)
return settings
}
function check (settings) {
let changes = 0
Object.keys(DEFAULTS).forEach(_iSet => {
if (settings[_iSet] === undefined || typeof settings[_iSet] !== typeof DEFAULTS[_iSet]) {
settings[_iSet] = DEFAULTS[_iSet]
changes++
}
})
Object.keys(DEFAULTS.tags).forEach(_iSet => {
if (settings.tags[_iSet] === undefined || typeof settings.tags[_iSet] !== typeof DEFAULTS.tags[_iSet]) {
settings.tags[_iSet] = DEFAULTS.tags[_iSet]
changes++
}
})
if (settings.downloadLocation === '') {
settings.downloadLocation = DEFAULTS.downloadLocation
changes++
}
[
'tracknameTemplate',
'albumTracknameTemplate',
'playlistTracknameTemplate',
'playlistNameTemplate',
'artistNameTemplate',
'albumNameTemplate',
'playlistFilenameTemplate',
'coverImageTemplate',
'artistImageTemplate',
'paddingSize'
].forEach(template => {
if (settings[template] === '') {
settings[template] = DEFAULTS[template]
changes++
}
})
return changes
}
module.exports = {
OverwriteOption,
FeaturesOption,
DEFAULTS,
save,
load
}

318
deemix/deemix/tagger.js Normal file
View File

@@ -0,0 +1,318 @@
const ID3Writer = require('./utils/id3-writer.js')
const Metaflac = require('metaflac-js2')
const fs = require('fs')
function tagID3 (path, track, save) {
const songBuffer = fs.readFileSync(path)
const tag = new ID3Writer(songBuffer)
tag.separateWithNull = String.fromCharCode(0)
if (save.title) tag.setFrame('TIT2', track.title)
if (save.artist && track.artists.length) {
if (save.multiArtistSeparator === 'default') {
tag.setFrame('TPE1', track.artists)
} else {
if (save.multiArtistSeparator === 'nothing') {
tag.setFrame('TPE1', [track.mainArtist.name])
} else {
tag.setFrame('TPE1', [track.artistsString])
}
// Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method
// https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists
if (save.artists) {
tag.setFrame('TXXX', {
description: 'ARTISTS',
value: track.artists
})
}
}
}
if (save.album) tag.setFrame('TALB', track.album.title)
if (save.albumArtist && track.album.artists.length) {
if (save.singleAlbumArtist && track.album.mainArtist.save) {
tag.setFrame('TPE2', [track.album.mainArtist.name])
} else {
tag.setFrame('TPE2', track.album.artists)
}
}
if (save.trackNumber) {
let trackNumber = String(track.trackNumber)
if (save.trackTotal) trackNumber += `/${track.album.trackTotal}`
tag.setFrame('TRCK', trackNumber)
}
if (save.discNumber) {
let discNumber = String(track.discNumber)
if (save.discTotal) discNumber += `/${track.album.discTotal}`
tag.setFrame('TPOS', discNumber)
}
if (save.genre) tag.setFrame('TCON', track.album.genre)
if (save.year) tag.setFrame('TYER', track.date.year)
// Referencing ID3 standard
// https://id3.org/id3v2.3.0#TDAT
// The 'Date' frame is a numeric string in the DDMM format.
if (save.date) tag.setFrame('TDAT', '' + track.date.day + track.date.month)
if (save.length) tag.setFrame('TLEN', parseInt(track.duration) * 1000)
if (save.bpm && track.bpm) tag.setFrame('TBPM', track.bpm)
if (save.label) tag.setFrame('TPUB', track.album.label)
if (save.isrc) tag.setFrame('TSRC', track.ISRC)
if (save.barcode) {
tag.setFrame('TXXX', {
description: 'BARCODE',
value: track.album.barcode
})
}
if (save.explicit) {
tag.setFrame('TXXX', {
description: 'ITUNESADVISORY',
value: track.explicit ? '1' : '0'
})
}
if (save.replayGain) {
tag.setFrame('TXXX', {
description: 'REPLAYGAIN_TRACK_GAIN',
value: track.replayGain
})
}
if (save.lyrics && track.lyrics.unsync) {
tag.setFrame('USLT', {
description: '',
lyrics: track.lyrics.unsync,
language: 'XXX'
})
}
if (save.syncedLyrics && track.lyrics.syncID3.length !== 0) {
tag.setFrame('SYLT', {
text: track.lyrics.syncID3,
type: 1,
timestampFormat: 2,
useUnicodeEncoding: true
})
}
const involvedPeople = []
Object.keys(track.contributors).forEach(role => {
if (['author', 'engineer', 'mixer', 'producer', 'writer'].includes(role)) {
track.contributors[role].forEach(person => {
involvedPeople.push([role, person])
})
} else if (role === 'composer' && save.composer) {
tag.setFrame('TCOM', track.contributors.composer)
}
})
if (involvedPeople.length && save.involvedPeople) tag.setFrame('IPLS', involvedPeople)
if (save.copyright && track.copyright) tag.setFrame('TCOP', track.copyright)
if ((save.savePlaylistAsCompilation && track.playlist) || track.album.recordType === 'compile') { tag.setFrame('TCMP', '1') }
if (save.source) {
tag.setFrame('TXXX', {
description: 'SOURCE',
value: 'Deezer'
})
tag.setFrame('TXXX', {
description: 'SOURCEID',
value: track.id
})
}
if (save.rating) {
let rank = (track.rank / 10000) * 2.55
rank = rank > 255 ? 255 : Math.round(rank)
tag.setFrame('POPM', {
rating: rank
})
}
if (save.cover && track.album.embeddedCoverPath) {
const coverArrayBuffer = fs.readFileSync(track.album.embeddedCoverPath)
if (coverArrayBuffer.length !== 0) {
tag.setFrame('APIC', {
type: 3,
data: coverArrayBuffer,
description: 'cover',
useUnicodeEncoding: save.coverDescriptionUTF8
})
}
}
tag.addTag()
let taggedSongBuffer = Buffer.from(tag.arrayBuffer)
if (taggedSongBuffer.slice(-128, -125).toString() === 'TAG') { taggedSongBuffer = taggedSongBuffer.slice(0, -128) }
if (save.saveID3v1) { taggedSongBuffer = tagID3v1(taggedSongBuffer, track, save) }
fs.writeFileSync(path, taggedSongBuffer)
}
function tagFLAC (path, track, save) {
const flac = new Metaflac(path)
flac.removeAllTags()
if (save.title) flac.setTag(`TITLE=${track.title}`)
if (save.artist && track.artists.length) {
if (save.multiArtistSeparator === 'default') {
track.artists.forEach(artist => {
flac.setTag(`ARTIST=${artist}`)
})
} else {
if (save.multiArtistSeparator === 'nothing') {
flac.setTag(`ARTIST=${track.mainArtist.name}`)
} else {
flac.setTag(`ARTIST=${track.artistsString}`)
}
// Tag ARTISTS is added to keep the multiartist support when using a non standard tagging method
// https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists
if (save.artists) {
track.artists.forEach(artist => {
flac.setTag(`ARTISTS=${artist}`)
})
}
}
}
if (save.album) flac.setTag(`ALBUM=${track.album.title}`)
if (save.albumArtist && track.album.artists.length) {
if (save.singleAlbumArtist && track.album.mainArtist.save) {
flac.setTag(`ALBUMARTIST=${track.album.mainArtist.name}`)
} else {
track.album.artists.forEach(artist => {
flac.setTag(`ALBUMARTIST=${artist}`)
})
}
}
if (save.trackNumber) flac.setTag(`TRACKNUMBER=${track.trackNumber}`)
if (save.trackTotal) flac.setTag(`TRACKTOTAL=${track.album.trackTotal}`)
if (save.discNumber) flac.setTag(`DISCNUMBER=${track.discNumber}`)
if (save.discTotal) flac.setTag(`DISCTOTAL=${track.album.discTotal}`)
if (save.genre) {
track.album.genre.forEach(genre => {
flac.setTag(`GENRE=${genre}`)
})
}
// YEAR tag is not suggested as a standard tag
// Being YEAR already contained in DATE will only use DATE instead
// Reference: https://www.xiph.org/vorbis/doc/v-comment.html#fieldnames
if (save.date) flac.setTag(`DATE=${track.dateString}`)
else if (save.year) flac.setTag(`DATE=${track.date.year}`)
if (save.length) flac.setTag(`LENGTH=${parseInt(track.duration) * 1000}`)
if (save.bpm && track.bpm) flac.setTag(`BPM=${track.bpm}`)
if (save.label) flac.setTag(`PUBLISHER=${track.album.label}`)
if (save.isrc) flac.setTag(`ISRC=${track.ISRC}`)
if (save.barcode) flac.setTag(`BARCODE=${track.album.barcode}`)
if (save.explicit) flac.setTag(`ITUNESADVISORY=${track.explicit ? '1' : '0'}`)
if (save.replayGain) flac.setTag(`REPLAYGAIN_TRACK_GAIN=${track.replayGain}`)
if (save.lyrics && track.lyrics.unsync) flac.setTag(`LYRICS=${track.lyrics.unsync}`)
Object.keys(track.contributors).forEach(role => {
if (['author', 'engineer', 'mixer', 'producer', 'writer', 'composer'].includes(role)) {
if ((save.involvedPeople && role !== 'composer') || (save.composer && role === 'composer')) {
track.contributors[role].forEach(person => {
flac.setTag(`${role.toUpperCase()}=${person}`)
})
}
} else if (role === 'musicpublisher' && save.involvedPeople) {
track.contributors.musicpublisher.forEach(person => {
flac.setTag(`ORGANIZATION=${person}`)
})
}
})
if (save.copyright && track.copyright) flac.setTag(`COPYRIGHT=${track.copyright}`)
if ((save.savePlaylistAsCompilation && track.playlist) || (track.album.recordType === 'compile')) { flac.setTag('COMPILATION=1') }
if (save.source) {
flac.setTag('SOURCE=Deezer')
flac.setTag(`SOURCEID=${track.id}`)
}
if (save.rating) {
const rank = Math.round(track.rank / 10000)
flac.setTag(`RATING=${rank}`)
}
if (save.cover && track.album.embeddedCoverPath) {
const picture = fs.readFileSync(track.album.embeddedCoverPath)
if (picture.length !== 0) flac.importPicture(picture)
}
flac.save()
}
const id3v1Genres = ['Blues', 'Classic Rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge', 'Hip-Hop', 'Jazz', 'Metal', 'New Age', 'Oldies', 'Other', 'Pop', 'Rhythm and Blues', 'Rap', 'Reggae', 'Rock', 'Techno', 'Industrial', 'Alternative', 'Ska', 'Death Metal', 'Pranks', 'Soundtrack', 'Euro-Techno', 'Ambient', 'Trip-Hop', 'Vocal', 'Jazz & Funk', 'Fusion', 'Trance', 'Classical', 'Instrumental', 'Acid', 'House', 'Game', 'Sound clip', 'Gospel', 'Noise', 'Alternative Rock', 'Bass', 'Soul', 'Punk', 'Space', 'Meditative', 'Instrumental Pop', 'Instrumental Rock', 'Ethnic', 'Gothic', 'Darkwave', 'Techno-Industrial', 'Electronic', 'Pop-Folk', 'Eurodance', 'Dream', 'Southern Rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian Rap', 'Pop/Funk', 'Jungle music', 'Native US', 'Cabaret', 'New Wave', 'Psychedelic', 'Rave', 'Showtunes', 'Trailer', 'Lo-Fi', 'Tribal', 'Acid Punk', 'Acid Jazz', 'Polka', 'Retro', 'Musical', 'Rock n Roll', 'Hard Rock', 'Folk', 'Folk-Rock', 'National Folk', 'Swing', 'Fast Fusion', 'Bebop', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock', 'Progressive Rock', 'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock', 'Big Band', 'Chorus', 'Easy Listening', 'Acoustic', 'Humour', 'Speech', 'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', 'Booty Bass', 'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power Ballad', 'Rhythmic Soul', 'Freestyle', 'Duet', 'Punk Rock', 'Drum Solo', 'A cappella', 'Euro-House', 'Dance Hall', 'Goa music', 'Drum & Bass', 'Club-House', 'Hardcore Techno', 'Terror', 'Indie', 'BritPop', 'Negerpunk', 'Polsk Punk', 'Beat', 'Christian Gangsta Rap', 'Heavy Metal', 'Black Metal', 'Crossover', 'Contemporary Christian', 'Christian Rock', 'Merengue', 'Salsa', 'Thrash Metal', 'Anime', 'Jpop', 'Synthpop', 'Abstract', 'Art Rock', 'Baroque', 'Bhangra', 'Big beat', 'Breakbeat', 'Chillout', 'Downtempo', 'Dub', 'EBM', 'Eclectic', 'Electro', 'Electroclash', 'Emo', 'Experimental', 'Garage', 'Global', 'IDM', 'Illbient', 'Industro-Goth', 'Jam Band', 'Krautrock', 'Leftfield', 'Lounge', 'Math Rock', 'New Romantic', 'Nu-Breakz', 'Post-Punk', 'Post-Rock', 'Psytrance', 'Shoegaze', 'Space Rock', 'Trop Rock', 'World Music', 'Neoclassical', 'Audiobook', 'Audio Theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie-Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient']
// Filters only Extended Ascii characters
function extAsciiFilter (string) {
let output = ''
string.split('').forEach((x) => {
if (x.charCodeAt(0) > 255) { output += '?' } else { output += x }
})
return output
}
function tagID3v1 (taggedSongBuffer, track, save) {
const tagBuffer = Buffer.alloc(128)
tagBuffer.write('TAG', 0) // Header
if (save.title) {
const trimmedTitle = extAsciiFilter(track.title.substring(0, 30))
tagBuffer.write(trimmedTitle, 3)
}
if (save.artist) {
let selectedArtist
if (track.artistsString) selectedArtist = track.artistsString
else selectedArtist = track.mainArtist.name
const trimmedArtist = extAsciiFilter(selectedArtist.substring(0, 30))
tagBuffer.write(trimmedArtist, 33)
}
if (save.album) {
const trimmedAlbum = extAsciiFilter(track.album.title.substring(0, 30))
tagBuffer.write(trimmedAlbum, 63)
}
if (save.year) {
const trimmedYear = track.date.year.substring(0, 4)
tagBuffer.write(trimmedYear, 93)
}
if (save.trackNumber) {
if (track.trackNumber <= 65535) {
if (track.trackNumber > 255) {
tagBuffer.writeUInt8(parseInt(track.trackNumber >> 8), 125)
tagBuffer.writeUInt8(parseInt(track.trackNumber & 255), 126)
} else {
tagBuffer.writeUInt8(parseInt(track.trackNumber), 126)
}
}
}
if (save.genre) {
const selectedGenre = track.album.genre[0]
if (id3v1Genres.includes(selectedGenre)) tagBuffer.writeUInt8(id3v1Genres.indexOf(selectedGenre), 127)
else tagBuffer.writeUInt8(255, 127)
} else {
tagBuffer.writeUInt8(255, 127)
}
// Save tags
const buffer = new ArrayBuffer(taggedSongBuffer.byteLength + 128)
const bufferWriter = new Uint8Array(buffer)
bufferWriter.set(new Uint8Array(taggedSongBuffer), 0)
bufferWriter.set(new Uint8Array(tagBuffer), taggedSongBuffer.byteLength)
return Buffer.from(buffer)
}
module.exports = {
tagID3,
tagFLAC,
tagID3v1
}

View File

@@ -0,0 +1,151 @@
const { removeDuplicateArtists, removeFeatures } = require('../utils/index.js')
const { Artist } = require('./Artist.js')
const { Date } = require('./Date.js')
const { Picture } = require('./Picture.js')
const { VARIOUS_ARTISTS } = require('./index.js')
class Album {
constructor (alb_id = '0', title = '', pic_md5 = '') {
this.id = alb_id
this.title = title
this.pic = new Picture(pic_md5, 'cover')
this.artist = { Main: [] }
this.artists = []
this.mainArtist = null
this.date = new Date()
this.dateString = ''
this.trackTotal = '0'
this.discTotal = '0'
this.embeddedCoverPath = ''
this.embeddedCoverURL = ''
this.explicit = false
this.genre = []
this.barcode = 'Unknown'
this.label = 'Unknown'
this.copyright = ''
this.recordType = 'album'
this.bitrate = 0
this.rootArtist = null
this.variousArtists = null
this.playlistId = null
this.owner = null
this.isPlaylist = false
}
parseAlbum (albumAPI) {
this.title = albumAPI.title
// Getting artist image ID
// ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
let art_pic = albumAPI.artist.picture_small
if (art_pic) art_pic = art_pic.slice(art_pic.indexOf('artist/') + 7, -24)
else art_pic = ''
this.mainArtist = new Artist(
albumAPI.artist.id,
albumAPI.artist.name,
'Main',
art_pic
)
if (albumAPI.root_artist) {
let art_pic = albumAPI.root_artist.picture_small
art_pic = art_pic.slice(art_pic.indexOf('artist/') + 7, -24)
this.rootArtist = new Artist(
albumAPI.root_artist.id,
albumAPI.root_artist.name,
'Root',
art_pic
)
}
albumAPI.contributors.forEach(artist => {
const isVariousArtists = String(artist.id) === VARIOUS_ARTISTS
const isMainArtist = artist.role === 'Main'
if (isVariousArtists) {
this.variousArtists = new Artist(
artist.id,
artist.name,
artist.role
)
return
}
if (!this.artists.includes(artist.name)) {
this.artists.push(artist.name)
}
if (isMainArtist || (!this.artist.Main.includes(artist.name) && !isMainArtist)) {
if (!this.artist[artist.role]) this.artist[artist.role] = []
this.artist[artist.role].push(artist.name)
}
})
this.trackTotal = albumAPI.nb_tracks
this.recordType = albumAPI.record_type || this.recordType
this.barcode = albumAPI.upc || this.barcode
this.label = albumAPI.label || this.label
this.explicit = Boolean(albumAPI.explicit_lyrics || false)
let release_date = albumAPI.release_date
if (albumAPI.original_release_date) release_date = albumAPI.original_release_date
if (release_date) {
this.date.year = release_date.slice(0, 4)
this.date.month = release_date.slice(5, 7)
this.date.day = release_date.slice(8, 10)
this.date.fixDayMonth()
}
this.discTotal = albumAPI.nb_disk || '1'
this.copyright = albumAPI.copyright || ''
if (this.pic.md5 === '') {
if (albumAPI.md5_image) {
this.pic.md5 = albumAPI.md5_image
} else if (albumAPI.cover_small) {
// Getting album cover MD5
// ex: https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg
const alb_pic = albumAPI.cover_small
this.pic.md5 = alb_pic.slice(alb_pic.indexOf('cover/') + 6, -24)
}
}
if (albumAPI.genres && albumAPI.genres.data && albumAPI.genres.data.length > 0) {
albumAPI.genres.data.forEach(genre => {
this.genre.push(genre.name)
})
}
}
makePlaylistCompilation (playlist) {
this.variousArtists = playlist.variousArtists
this.mainArtist = playlist.mainArtist
this.title = playlist.title
this.rootArtist = playlist.rootArtist
this.artist = playlist.artist
this.artists = playlist.artists
this.trackTotal = playlist.trackTotal
this.recordType = playlist.recordType
this.barcode = playlist.barcode
this.label = playlist.label
this.explicit = playlist.explicit
this.date = playlist.date
this.discTotal = playlist.discTotal
this.playlistID = playlist.playlistID
this.owner = playlist.owner
this.pic = playlist.pic
this.isPlaylist = true
}
removeDuplicateArtists () {
[this.artist, this.artists] = removeDuplicateArtists(this.artist, this.artists)
}
getCleanTitle () {
return removeFeatures(this.title)
}
}
module.exports = {
Album
}

View File

@@ -0,0 +1,20 @@
const { Picture } = require('./Picture.js')
const { VARIOUS_ARTISTS } = require('./index.js')
class Artist {
constructor (art_id = '0', name = '', role = '', pic_md5 = '') {
this.id = String(art_id)
this.name = name
this.pic = new Picture(pic_md5, 'artist')
this.role = role
this.save = true
}
isVariousArtists () {
return this.id === VARIOUS_ARTISTS
}
}
module.exports = {
Artist
}

View File

@@ -0,0 +1,27 @@
class Date {
constructor (day = '00', month = '00', year = 'XXXX') {
this.day = day
this.month = month
this.year = year
this.fixDayMonth()
}
fixDayMonth () {
if (parseInt(this.month) > 12) {
const monthTemp = this.month
this.month = this.day
this.day = monthTemp
}
}
format (template) {
template = template.replaceAll(/D+/g, this.day)
template = template.replaceAll(/M+/g, this.month)
template = template.replaceAll(/Y+/g, this.year)
return template
}
}
module.exports = {
Date
}

View File

@@ -0,0 +1,158 @@
class IDownloadObject {
constructor (obj) {
this.type = obj.type
this.id = obj.id
this.bitrate = obj.bitrate
this.title = obj.title
this.artist = obj.artist
this.cover = obj.cover
this.explicit = obj.explicit || false
this.size = obj.size
this.downloaded = obj.downloaded || 0
this.failed = obj.failed || 0
this.progress = obj.progress || 0
this.errors = obj.errors || []
this.files = obj.files || []
this.extrasPath = obj.extrasPath || ''
this.progressNext = 0
this.uuid = `${this.type}_${this.id}_${this.bitrate}`
this.isCanceled = false
this.__type__ = null
}
toDict () {
return {
type: this.type,
id: this.id,
bitrate: this.bitrate,
uuid: this.uuid,
title: this.title,
artist: this.artist,
cover: this.cover,
explicit: this.explicit,
size: this.size,
downloaded: this.downloaded,
failed: this.failed,
progress: this.progress,
errors: this.errors,
files: this.files,
extrasPath: this.extrasPath,
__type__: this.__type__
}
}
getResettedDict () {
const item = this.toDict()
item.downloaded = 0
item.failed = 0
item.progress = 0
item.errors = []
item.files = []
return item
}
getSlimmedDict () {
const light = this.toDict()
const propertiesToDelete = ['single', 'collection', 'plugin', 'conversion_data']
propertiesToDelete.forEach((property) => {
if (Object.keys(light).includes(property)) {
delete light[property]
}
})
return light
}
getEssentialDict () {
return {
type: this.type,
id: this.id,
bitrate: this.bitrate,
uuid: this.uuid,
title: this.title,
artist: this.artist,
cover: this.cover,
explicit: this.explicit,
size: this.size,
extrasPath: this.extrasPath
}
}
updateProgress (listener) {
if (Math.floor(this.progressNext) !== this.progress && Math.floor(this.progressNext) % 2 === 0) {
this.progress = Math.floor(this.progressNext)
if (listener) listener.send('updateQueue', { uuid: this.uuid, progress: this.progress })
}
}
}
class Single extends IDownloadObject {
constructor (obj) {
super(obj)
this.size = 1
this.single = obj.single
this.__type__ = 'Single'
}
toDict () {
const item = super.toDict()
item.single = this.single
return item
}
completeTrackProgress (listener) {
this.progressNext = 100
this.updateProgress(listener)
}
removeTrackProgress (listener) {
this.progressNext = 0
this.updateProgress(listener)
}
}
class Collection extends IDownloadObject {
constructor (obj) {
super(obj)
this.collection = obj.collection
this.__type__ = 'Collection'
}
toDict () {
const item = super.toDict()
item.collection = this.collection
return item
}
completeTrackProgress (listener) {
this.progressNext += (1 / this.size) * 100
this.updateProgress(listener)
}
removeTrackProgress (listener) {
this.progressNext -= (1 / this.size) * 100
this.updateProgress(listener)
}
}
class Convertable extends Collection {
constructor (obj) {
super(obj)
this.plugin = obj.plugin
this.conversion_data = obj.conversion_data
this.__type__ = 'Convertable'
}
toDict () {
const item = super.toDict()
item.plugin = this.plugin
item.conversion_data = this.conversion_data
return item
}
}
module.exports = {
IDownloadObject,
Single,
Collection,
Convertable
}

View File

@@ -0,0 +1,36 @@
const { decode } = require('html-entities')
class Lyrics {
constructor (lyr_id = '0') {
this.id = lyr_id
this.sync = ''
this.unsync = ''
this.syncID3 = []
}
parseLyrics (lyricsAPI) {
this.unsync = lyricsAPI.LYRICS_TEXT || ''
if (lyricsAPI.LYRICS_SYNC_JSON) {
const syncLyricsJson = lyricsAPI.LYRICS_SYNC_JSON
let timestamp = ''
let milliseconds = 0
for (let line = 0; line < syncLyricsJson.length; line++) {
const currentLine = decode(syncLyricsJson[line].line)
if (currentLine !== '') {
timestamp = syncLyricsJson[line].lrc_timestamp
milliseconds = parseInt(syncLyricsJson[line].milliseconds)
this.syncID3.push([currentLine, milliseconds])
} else {
let notEmptyLine = line + 1
while (syncLyricsJson[notEmptyLine].line === '') notEmptyLine += 1
timestamp = syncLyricsJson[notEmptyLine].lrc_timestamp
}
this.sync += timestamp + currentLine + '\r\n'
}
}
}
}
module.exports = {
Lyrics
}

View File

@@ -0,0 +1,37 @@
class Picture {
constructor (md5 = '', pic_type = '') {
this.md5 = md5
this.type = pic_type
}
getURL (size, format) {
const url = `https://e-cdns-images.dzcdn.net/images/${this.type}/${this.md5}/${size}x${size}`
if (format.startsWith('jpg')) {
let quality = 80
if (format.includes('-')) quality = parseInt(format.substr(4))
format = 'jpg'
return url + `-000000-${quality}-0-0.jpg`
}
if (format === 'png') {
return url + '-none-100-0-0.png'
}
return url + '.jpg'
}
}
class StaticPicture {
constructor (url) {
this.staticURL = url
}
getURL () {
return this.staticURL
}
}
module.exports = {
Picture,
StaticPicture
}

View File

@@ -0,0 +1,53 @@
const { Artist } = require('./Artist.js')
const { Date } = require('./Date.js')
const { Picture, StaticPicture } = require('./Picture.js')
class Playlist {
constructor (playlistAPI) {
this.id = `pl_${playlistAPI.id}`
this.title = playlistAPI.title
this.artist = { Main: [] }
this.artists = []
this.trackTotal = playlistAPI.nb_tracks
this.recordType = 'compile'
this.barcode = ''
this.label = ''
this.explicit = playlistAPI.explicit
this.genre = ['Compilation']
const year = playlistAPI.creation_date.slice(0, 4)
const month = playlistAPI.creation_date.slice(5, 7)
const day = playlistAPI.creation_date.slice(8, 10)
this.date = new Date(day, month, year)
this.discTotal = '1'
this.playlistID = playlistAPI.id
this.owner = playlistAPI.creator
if (playlistAPI.picture_small.includes('dzcdn.net')) {
const url = playlistAPI.picture_small
let picType = url.slice(url.indexOf('images/') + 7)
picType = picType.slice(0, picType.indexOf('/'))
const md5 = url.slice(url.indexOf(picType + '/') + picType.length + 1, -24)
this.pic = new Picture(md5, picType)
} else {
this.pic = new StaticPicture(playlistAPI.picture_xl)
}
if (playlistAPI.various_artist) {
let pic_md5 = playlistAPI.various_artist.picture_small
pic_md5 = pic_md5.slice(pic_md5.indexOf('artist/') + 7, -24)
this.variousArtists = new Artist(
playlistAPI.various_artist.id,
playlistAPI.various_artist.name,
playlistAPI.various_artist.role,
pic_md5
)
this.mainArtist = this.variousArtists
}
}
}
module.exports = {
Playlist
}

View File

@@ -0,0 +1,354 @@
const { map_track, map_album } = require('deezer-js').utils
const { Artist } = require('./Artist.js')
const { Album } = require('./Album.js')
const { Playlist } = require('./Playlist.js')
const { Picture } = require('./Picture.js')
const { Lyrics } = require('./Lyrics.js')
const { Date: dzDate } = require('./Date.js')
const { VARIOUS_ARTISTS } = require('./index.js')
const { changeCase } = require('../utils/index.js')
const { FeaturesOption } = require('../settings.js')
const { NoDataToParse, AlbumDoesntExists } = require('../errors.js')
const {
generateReplayGainString,
removeDuplicateArtists,
removeFeatures,
andCommaConcat
} = require('../utils/index.js')
class Track {
constructor () {
this.id = '0'
this.title = ''
this.MD5 = ''
this.mediaVersion = ''
this.trackToken = ''
this.trackTokenExpiration = 0
this.duration = 0
this.fallbackID = '0'
this.albumsFallback = []
this.filesizes = {}
this.local = false
this.mainArtist = null
this.artist = { Main: [] }
this.artists = []
this.album = null
this.trackNumber = '0'
this.discNumber = '0'
this.date = new dzDate()
this.lyrics = null
this.bpm = 0
this.contributors = {}
this.copyright = ''
this.explicit = false
this.ISRC = ''
this.replayGain = ''
this.playlist = null
this.position = null
this.searched = false
this.bitrate = 0
this.dateString = ''
this.artistsString = ''
this.mainArtistsString = ''
this.featArtistsString = ''
this.fullArtistsString = ''
this.urls = {}
}
parseEssentialData (trackAPI) {
this.id = String(trackAPI.id)
this.duration = trackAPI.duration
this.trackToken = trackAPI.track_token
this.trackTokenExpiration = trackAPI.track_token_expire
this.MD5 = trackAPI.md5_origin
this.mediaVersion = trackAPI.media_version
this.filesizes = trackAPI.filesizes
this.fallbackID = '0'
if (trackAPI.fallback_id) this.fallbackID = trackAPI.fallback_id
this.local = parseInt(this.id) < 0
this.urls = {}
}
async parseData (dz, id, trackAPI, albumAPI, playlistAPI) {
/* if (id && (!trackAPI || trackAPI && !trackAPI.track_token)) { */ // mickey
if (id && (!trackAPI || (trackAPI && !trackAPI.md5_origin))) {
let trackAPI_new = await dz.gw.get_track_with_fallback(id)
trackAPI_new = map_track(trackAPI_new)
if (!trackAPI) trackAPI = {}
/* trackAPI = {...trackAPI_new, ...trackAPI} */ // mickey
trackAPI = { ...trackAPI, ...trackAPI_new }
} else if (!trackAPI) { throw new NoDataToParse() }
this.parseEssentialData(trackAPI)
// only public api has bpm
if (!trackAPI.bpm && !this.local) {
try {
const trackAPI_new = await dz.api.get_track(trackAPI.id)
trackAPI_new.release_date = trackAPI.release_date
trackAPI = { ...trackAPI, ...trackAPI_new }
} catch { /* empty */ }
}
if (this.local) {
this.parseLocalTrackData(trackAPI)
} else {
this.parseTrack(trackAPI)
// Get Lyrics Data
if (!trackAPI.lyrics && this.lyrics.id !== '0') {
try { trackAPI.lyrics = await dz.gw.get_track_lyrics(this.id) } catch { this.lyrics.id = '0' }
}
if (this.lyrics.id !== '0') { this.lyrics.parseLyrics(trackAPI.lyrics) }
// Parse Album Data
this.album = new Album(trackAPI.album.id, trackAPI.album.title, trackAPI.album.md5_origin || '')
// Get album Data
if (!albumAPI) {
try { albumAPI = await dz.api.get_album(this.album.id) } catch { albumAPI = null }
}
// Get album_gw Data
// Only gw has disk number
if (!albumAPI || (albumAPI && !albumAPI.nb_disk)) {
let albumAPI_gw
try {
albumAPI_gw = await dz.gw.get_album(this.album.id)
albumAPI_gw = map_album(albumAPI_gw)
} catch { albumAPI_gw = {} }
if (!albumAPI) albumAPI = {}
albumAPI = { ...albumAPI_gw, ...albumAPI }
}
if (!albumAPI) throw new AlbumDoesntExists()
this.album.parseAlbum(albumAPI)
// albumAPI_gw doesn't contain the artist cover
// Getting artist image ID
// ex: https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg
if (!this.album.mainArtist.pic.md5) {
const artistAPI = await dz.api.get_artist(this.album.mainArtist.id)
this.album.mainArtist.pic.md5 = artistAPI.picture_small.slice(artistAPI.picture_small.search('artist/') + 7, -24)
}
// Fill missing data
if (this.album.date && !this.date) this.date = this.album.date
if (trackAPI.genres) {
trackAPI.genres.forEach((genre) => {
if (!this.album.genre.includes(genre)) this.album.genre.push(genre)
})
}
}
// Remove unwanted charaters in track name
// Example: track/127793
this.title = this.title.replace(/\s\s+/g, ' ')
// Make sure there is at least one artist
if (!this.artist.Main.length) {
this.artist.Main = [this.mainArtist.name]
}
this.position = trackAPI.position
if (playlistAPI) { this.playlist = new Playlist(playlistAPI) }
this.generateMainFeatStrings()
return this
}
parseLocalTrackData (trackAPI) {
// Local tracks has only the trackAPI_gw page and
// contains only the tags provided by the file
this.title = trackAPI.title
this.album = new Album(trackAPI.album.title)
this.album.pic = new Picture(
trackAPI.md5_image || '',
'cover'
)
this.mainArtist = new Artist('0', trackAPI.artist.name, 'Main')
this.artists = [trackAPI.artist.name]
this.artist = {
Main: [trackAPI.artist.name]
}
this.album.artist = this.artist
this.album.artists = this.artists
this.album.date = this.date
this.album.mainArtist = this.mainArtist
}
parseTrack (trackAPI) {
this.title = trackAPI.title
this.discNumber = trackAPI.disk_number
this.explicit = trackAPI.explicit_lyrics
this.copyright = trackAPI.copyright
if (trackAPI.gain) this.replayGain = generateReplayGainString(trackAPI.gain)
this.ISRC = trackAPI.isrc
this.trackNumber = trackAPI.track_position
this.contributors = trackAPI.song_contributors
this.rank = trackAPI.rank
this.bpm = trackAPI.bpm
this.lyrics = new Lyrics(trackAPI.lyrics_id || '0')
this.mainArtist = new Artist(
trackAPI.artist.id,
trackAPI.artist.name,
'Main',
trackAPI.artist.md5_image
)
if (trackAPI.physical_release_date) {
this.date.day = trackAPI.physical_release_date.slice(8, 10)
this.date.month = trackAPI.physical_release_date.slice(5, 7)
this.date.year = trackAPI.physical_release_date.slice(0, 4)
this.date.fixDayMonth()
}
trackAPI.contributors.forEach(artist => {
const isVariousArtists = String(artist.id) === VARIOUS_ARTISTS
const isMainArtist = artist.role === 'Main'
if (trackAPI.contributors.length > 1 && isVariousArtists) return
if (!this.artists.includes(artist.name)) { this.artists.push(artist.name) }
if (isMainArtist || (!this.artist.Main.includes(artist.name) && !isMainArtist)) {
if (!this.artist[artist.role]) { this.artist[artist.role] = [] }
this.artist[artist.role].push(artist.name)
}
})
if (trackAPI.alternative_albums) {
trackAPI.alternative_albums.data.forEach(album => {
if (album.RIGHTS.STREAM_ADS_AVAILABLE || album.RIGHTS.STREAM_SUB_AVAILABLE) { this.albumsFallback.push(album.ALB_ID) }
})
}
}
removeDuplicateArtists () {
[this.artist, this.artists] = removeDuplicateArtists(this.artist, this.artists)
}
getCleanTitle () {
return removeFeatures(this.title)
}
getFeatTitle () {
if (this.featArtistsString && !this.title.toLowerCase().includes('feat.')) {
return `${this.title} (${this.featArtistsString})`
}
return this.title
}
generateMainFeatStrings () {
this.mainArtistsString = andCommaConcat(this.artist.Main)
this.fullArtistsString = `${this.mainArtistsString}`
this.featArtistsString = ''
if (this.artist.Featured) {
this.featArtistsString = `feat. ${andCommaConcat(this.artist.Featured)}`
this.fullArtistsString += ` ${this.featArtistsString}`
}
}
async checkAndRenewTrackToken (dz) {
const now = new Date()
const expiration = new Date(this.trackTokenExpiration * 1000)
if (now > expiration) {
const newTrack = await dz.gw.get_track_with_fallback(this.id)
this.trackToken = newTrack.TRACK_TOKEN
this.trackTokenExpiration = newTrack.TRACK_TOKEN_EXPIRE
}
}
applySettings (settings) {
// Check if should save the playlist as a compilation
if (settings.tags.savePlaylistAsCompilation && this.playlist) {
this.trackNumber = this.position
this.discNumber = '1'
this.album.makePlaylistCompilation(this.playlist)
} else {
if (this.album.date) this.date = this.album.date
}
this.dateString = this.date.format(settings.dateFormat)
this.album.dateString = this.album.date.format(settings.dateFormat)
if (this.playlist) this.playlist.dateString = this.playlist.date.format(settings.dateFormat)
// Check various artist option
if (settings.albumVariousArtists && this.album.variousArtists) {
const artist = this.album.variousArtists
const isMainArtist = artist.role === 'Main'
if (!this.album.artists.includes(artist.name)) { this.album.artists.push(artist.name) }
if (isMainArtist || (!this.album.artist.Main.includes(artist.name) && !isMainArtist)) {
if (!this.album.artist[artist.role]) { this.album.artist[artist.role] = [] }
this.album.artist[artist.role].push(artist.name)
}
}
this.album.mainArtist.save = (!this.album.mainArtist.isVariousArtists() || (settings.albumVariousArtists && this.album.mainArtist.isVariousArtists()))
// Check removeDuplicateArtists
if (settings.removeDuplicateArtists) {
this.removeDuplicateArtists()
this.generateMainFeatStrings()
}
// Check if user wants the feat in the title
if (settings.featuredToTitle === FeaturesOption.REMOVE_TITLE) {
this.title = this.getCleanTitle()
} else if (settings.featuredToTitle === FeaturesOption.MOVE_TITLE) {
this.title = this.getFeatTitle()
} else if (settings.featuredToTitle === FeaturesOption.REMOVE_TITLE_ALBUM) {
this.title = this.getCleanTitle()
this.album.title = this.album.getCleanTitle()
}
// Remove (Album Version) from tracks that have that
if (settings.removeAlbumVersion && this.title.includes('Album Version')) {
this.title = this.title.replace(/ ?\(Album Version\)/g, '').trim()
}
// Change title and artist casing if needed
if (settings.titleCasing !== 'nothing') {
this.title = changeCase(this.title, settings.titleCasing)
}
if (settings.artistCasing !== 'nothing') {
this.mainArtist.name = changeCase(this.mainArtist.name, settings.artistCasing)
this.artists.forEach((artist, i) => {
this.artists[i] = changeCase(artist, settings.artistCasing)
})
Object.keys(this.artist).forEach((art_type) => {
this.artist[art_type].forEach((artist, i) => {
this.artist[art_type][i] = changeCase(artist, settings.artistCasing)
})
})
this.generateMainFeatStrings()
}
// Generate artist tag
if (settings.tags.multiArtistSeparator === 'default') {
if (settings.featuredToTitle === FeaturesOption.MOVE_TITLE) {
this.artistsString = this.artist.Main.join(', ')
} else {
this.artistString = this.artists.join(', ')
}
} else if (settings.tags.multiArtistSeparator === 'andFeat') {
this.artistsString = this.mainArtistsString
if (this.featArtistsString && settings.featuredToTitle !== FeaturesOption.MOVE_TITLE) { this.artistsString += ` ${this.featArtistsString}` }
} else {
const separator = settings.tags.multiArtistSeparator
if (settings.featuredToTitle === FeaturesOption.MOVE_TITLE) {
this.artistsString = this.artist.Main.join(separator)
} else {
this.artistsString = this.artists.join(separator)
}
}
}
}
module.exports = {
Track
}

View File

@@ -0,0 +1,5 @@
const VARIOUS_ARTISTS = '5080'
module.exports = {
VARIOUS_ARTISTS
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,56 @@
const crypto = require('crypto')
let Blowfish
try {
Blowfish = require('./blowfish.js')
} catch (e) { /* empty */ }
function _md5 (data, type = 'binary') {
const md5sum = crypto.createHash('md5')
md5sum.update(Buffer.from(data, type))
return md5sum.digest('hex')
}
function _ecbCrypt (key, data) {
const cipher = crypto.createCipheriv('aes-128-ecb', Buffer.from(key), Buffer.from(''))
cipher.setAutoPadding(false)
return Buffer.concat([cipher.update(data, 'binary'), cipher.final()]).toString('hex').toLowerCase()
}
function _ecbDecrypt (key, data) {
const cipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(key), Buffer.from(''))
cipher.setAutoPadding(false)
return Buffer.concat([cipher.update(data, 'binary'), cipher.final()]).toString('hex').toLowerCase()
}
function generateBlowfishKey (trackId) {
const SECRET = 'g4el58wc0zvf9na1'
const idMd5 = _md5(trackId.toString(), 'ascii')
let bfKey = ''
for (let i = 0; i < 16; i++) {
bfKey += String.fromCharCode(idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i))
}
return String(bfKey)
}
function decryptChunk (chunk, blowFishKey) {
const ciphers = crypto.getCiphers()
if (ciphers.includes('bf-cbc')) {
const cipher = crypto.createDecipheriv('bf-cbc', blowFishKey, Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]))
cipher.setAutoPadding(false)
return Buffer.concat([cipher.update(chunk), cipher.final()])
}
if (Blowfish) {
const cipher = new Blowfish(blowFishKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL)
cipher.setIv(Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]))
return Buffer.from(cipher.decode(chunk, Blowfish.TYPE.UINT8_ARRAY))
}
throw new Error("Can't find a way to decrypt chunks")
}
module.exports = {
_md5,
_ecbCrypt,
_ecbDecrypt,
generateBlowfishKey,
decryptChunk
}

View File

@@ -0,0 +1,53 @@
const got = require('got')
const {CookieJar} = require('tough-cookie')
const {_md5} = require('./crypto.js')
const { USER_AGENT_HEADER } = require('./index.js')
const CLIENT_ID = "172365"
const CLIENT_SECRET = "fb0bec7ccc063dab0417eb7b0d847f34"
async function getAccessToken(email, password){
let accessToken = null
password = _md5(password, 'utf8')
const hash = _md5([CLIENT_ID, email, password, CLIENT_SECRET].join(''), 'utf8')
try {
let response = await got.get("https://api.deezer.com/auth/token",{
searchParams: {
app_id: CLIENT_ID,
login: email,
password: password,
hash
},
https: {rejectUnauthorized: false},
headers: {"User-Agent": USER_AGENT_HEADER}
}).json()
accessToken = response.access_token
if (accessToken == "undefined") accessToken = null
} catch { /*empty*/ }
return accessToken
}
async function getArlFromAccessToken(accessToken){
if (!accessToken) return null
let arl = null
let cookieJar = new CookieJar()
try {
await got.get("https://api.deezer.com/platform/generic/track/3135556", {
headers: {"Authorization": `Bearer ${accessToken}`, "User-Agent": USER_AGENT_HEADER},
https: {rejectUnauthorized: false},
cookieJar
})
let response = await got.get('https://www.deezer.com/ajax/gw-light.php?method=user.getArl&input=3&api_version=1.0&api_token=null', {
headers: {"User-Agent": USER_AGENT_HEADER},
https: {rejectUnauthorized: false},
cookieJar
}).json()
arl = response.results
} catch { /*empty*/ }
return arl
}
module.exports = {
getAccessToken,
getArlFromAccessToken
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,175 @@
const stream = require('stream')
const {promisify} = require('util')
const pipeline = promisify(stream.pipeline)
const { accessSync, constants } = require('fs')
const { ErrorMessages } = require('../errors.js')
const USER_AGENT_HEADER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36"
function canWrite(path){
try{
accessSync(path, constants.R_OK | constants.W_OK)
}catch{
return false
}
return true
}
function generateReplayGainString(trackGain){
return `${Math.round((parseFloat(trackGain) + 18.4)*-100)/100} dB`
}
function changeCase(txt, type){
switch (type) {
case 'lower': return txt.toLowerCase()
case 'upper': return txt.toUpperCase()
case 'start':
txt = txt.trim().split(" ")
for (let i = 0; i < txt.length; i++) {
if (['(', '{', '[', "'", '"'].some(bracket => ( txt[i].length > 1 && txt[i].startsWith(bracket) ) )) {
txt[i] = txt[i][0] + txt[i][1].toUpperCase() + txt[i].substr(2).toLowerCase()
} else if (txt[i].length > 1) {
txt[i] = txt[i][0].toUpperCase() + txt[i].substr(1).toLowerCase()
} else {
txt[i] = txt[i][0].toUpperCase()
}
}
return txt.join(" ")
case 'sentence': return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
default: return txt
}
}
function removeFeatures(title){
let clean = title
let found = false
let pos
if (clean.search(/[\s(]\(?\s?feat\.?\s/gi) != -1){
pos = clean.search(/[\s(]\(?\s?feat\.?\s/gi)
found = true
}
if (clean.search(/[\s(]\(?\s?ft\.?\s/gi) != -1){
pos = clean.search(/[\s(]\(?\s?ft\.?\s/gi)
found = true
}
const openBracket = clean[pos] == '(' || clean[pos+1] == '('
const otherBracket = clean.indexOf('(', pos+2)
if (found) {
let tempTrack = clean.slice(0, pos)
if (clean.includes(')') && openBracket)
tempTrack += clean.slice(clean.indexOf(')', pos+2)+1)
if (!openBracket && otherBracket != -1)
tempTrack += ` ${clean.slice(otherBracket)}`
clean = tempTrack.trim()
clean = clean.replace(/\s\s+/g, ' ') // remove extra spaces
}
return clean
}
function andCommaConcat(lst){
const tot = lst.length
let result = ""
lst.forEach((art, i) => {
result += art
if (tot != i+1){
if (tot - 1 == i+1){
result += " & "
} else {
result += ", "
}
}
})
return result
}
function uniqueArray(arr){
arr.forEach((namePrinc, iPrinc) => {
arr.forEach((nameRest, iRest) => {
if (iPrinc != iRest && nameRest.toLowerCase().includes(namePrinc.toLowerCase())){
arr.splice(iRest, 1)
}
})
})
return arr
}
function shellEscape(s){
if (typeof s !== 'string') return ''
if (!(/[^\w@%+=:,./-]/g.test(s))) return s
return "'" + s.replaceAll("'", "'\"'\"'") + "'"
}
function removeDuplicateArtists(artist, artists){
artists = uniqueArray(artists)
Object.keys(artist).forEach((role) => {
artist[role] = uniqueArray(artist[role])
})
return [artist, artists]
}
function formatListener(key, data){
let message = ""
switch (key) {
case "startAddingArtist": return `Started gathering ${data.name}'s albums (${data.id})`
case "finishAddingArtist": return `Finished gathering ${data.name}'s albums (${data.id})`
case "updateQueue":
message = `[${data['uuid']}]`
if (data.downloaded) message += ` Completed download of ${data.downloadPath.slice(data.extrasPath.length+1)}`
if (data.failed) message += ` ${data.data.artist} - ${data.data.title} :: ${data.error}`
if (data.progress) message += ` Download at ${data.progress}%`
if (data.conversion) message += ` Conversion at ${data.conversion}%`
return message
case "downloadInfo":
message = data.state
switch (data.state) {
case "getTags": message = "Getting tags."; break;
case "gotTags": message = "Tags got."; break;
case "getBitrate": message = "Getting download URL."; break;
case "bitrateFallback": message = "Desired bitrate not found, falling back to lower bitrate."; break;
case "searchFallback": message = "This track has been searched for, result might not be 100% exact."; break;
case "gotBitrate": message = "Download URL got."; break;
case "getAlbumArt": message = "Downloading album art."; break;
case "gotAlbumArt": message = "Album art downloaded."; break;
case "downloading":
message = "Downloading track.";
if (data.alreadyStarted) message += ` Recovering download from ${data.value}.`
else message += ` Downloading ${data.value} bytes.`
break;
case "downloadTimeout": message = "Deezer timedout when downloading track, retrying..."; break;
case "downloaded": message = "Track downloaded."; break;
case "alreadyDownloaded": message = "Track already downloaded."; break;
case "tagging": message = "Tagging track."; break;
case "tagged": message = "Track tagged."; break;
case "stderr": return `ExecuteCommand Error: ${data.data.stderr}`
case "stdout": return `ExecuteCommand Output: ${data.data.stdout}`
}
return `[${data.uuid}] ${data.data.artist} - ${data.data.title} :: ${message}`
case "downloadWarn":
message = `[${data.uuid}] ${data.data.artist} - ${data.data.title} :: ${ErrorMessages[data.state]} `
switch (data.solution) {
case 'fallback': message += "Using fallback id."; break;
case 'search': message += "Searching for alternative."; break;
}
return message
case "currentItemCancelled": return `Current item cancelled (${data})`
case "removedFromQueue": return `[${data}] Removed from the queue`
case "finishDownload": return `[${data}] Finished downloading`
case "startConversion": return `[${data}] Started converting`
case "finishConversion": return `[${data.uuid}] Finished converting`
default: return message
}
}
module.exports = {
USER_AGENT_HEADER,
generateReplayGainString,
removeFeatures,
andCommaConcat,
uniqueArray,
removeDuplicateArtists,
pipeline,
canWrite,
changeCase,
shellEscape,
formatListener
}

View File

@@ -0,0 +1,89 @@
const { sep } = require('path')
const { homedir } = require('os')
const fs = require('fs')
const { canWrite } = require('./index.js')
let homedata = homedir()
let userdata = ""
let musicdata = ""
function checkPath(path){
if (path === "") return ""
if (!fs.existsSync(path)) return ""
if (!canWrite(path)) return ""
return path
}
function getConfigFolder(){
if (userdata != "") return userdata
if (process.env.XDG_CONFIG_HOME && userdata === ""){
userdata = `${process.env.XDG_CONFIG_HOME}${sep}`
userdata = checkPath(userdata)
}
if (process.env.APPDATA && userdata === ""){
userdata = `${process.env.APPDATA}${sep}`
userdata = checkPath(userdata)
}
if (process.platform == "darwin" && userdata === ""){
userdata = `${homedata}/Library/Application Support/`
userdata = checkPath(userdata)
}
if (userdata === ""){
userdata = `${homedata}${sep}.config${sep}`
userdata = checkPath(userdata)
}
if (userdata === "") userdata = `${process.cwd()}${sep}config${sep}`
else userdata += `deemix${sep}`
if (process.env.DEEMIX_DATA_DIR) userdata = process.env.DEEMIX_DATA_DIR
return userdata
}
function getMusicFolder(){
if (musicdata != "") return musicdata
if (process.env.XDG_MUSIC_DIR && musicdata === ""){
musicdata = `${process.env.XDG_MUSIC_DIR}${sep}`
musicdata = checkPath(musicdata)
}
if (fs.existsSync(`${homedata}${sep}.config${sep}user-dirs.dirs`)){
const userDirs = fs.readFileSync(`${homedata}${sep}.config${sep}user-dirs.dirs`).toString()
musicdata = userDirs.match(/XDG_MUSIC_DIR="(.*)"/)[1]
musicdata = musicdata.replace(/\$([A-Z_]+[A-Z0-9_]*)/ig, (_, envName) => process.env[envName])
musicdata += sep
musicdata = checkPath(musicdata)
}
if (process.platform == 'win32' && musicdata === ""){
try {
const { execSync } = require('child_process')
const musicKeys = ["My Music", "{4BD8D571-6D19-48D3-BE97-422220080E43}"]
let regData = execSync('reg.exe query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders"').toString().split('\r\n')
for (let i = 0; i < regData.length; i++){
let line = regData[i]
if (line === "") continue
if (i == 1) continue
line = line.split(' ')
if (musicKeys.includes(line[1])){
musicdata = line[3] + sep
break;
}
}
musicdata = checkPath(musicdata)
} catch {/* empty */}
}
if (musicdata === ""){
musicdata = `${homedata}${sep}Music${sep}`
musicdata = checkPath(musicdata)
}
if (musicdata === "") musicdata = `${process.cwd()}${sep}music${sep}`
else musicdata += `deemix Music${sep}`
if (process.env.DEEMIX_MUSIC_DIR) musicdata = process.env.DEEMIX_MUSIC_DIR
return musicdata
}
module.exports = {
getConfigFolder,
getMusicFolder
}

View File

@@ -0,0 +1,259 @@
const { TrackFormats } = require('deezer-js')
const { Date: dzDate } = require('../types/Date.js')
const bitrateLabels = {
[TrackFormats.MP4_RA3]: "360 HQ",
[TrackFormats.MP4_RA2]: "360 MQ",
[TrackFormats.MP4_RA1]: "360 LQ",
[TrackFormats.FLAC] : "FLAC",
[TrackFormats.MP3_320]: "320",
[TrackFormats.MP3_128]: "128",
[TrackFormats.DEFAULT]: "128",
[TrackFormats.LOCAL] : "MP3"
}
function fixName(txt, char='_'){
txt = txt+""
txt = txt.replace(/[\0/\\:*?"<>|]/g, char)
return txt.normalize('NFC')
}
function fixLongName(name){
if (name.includes('/')){
let sepName = name.split('/')
name = ""
sepName.forEach((txt) => {
txt = fixLongName(txt)
name += `${txt}/`
})
name = name.slice(0, -1)
} else {
name = name.slice(0, 200)
}
return name
}
function antiDot(str){
while(str[str.length-1] == "." || str[str.length-1] == " " || str[str.length-1] == "\n"){
str = str.slice(0,-1)
}
if(str.length < 1){
str = "dot"
}
return str
}
function pad(num, max_val, settings) {
let paddingSize;
if (parseInt(settings.paddingSize) == 0) {
paddingSize = (max_val+"").length
} else{
paddingSize = ((10 ** (parseInt(settings.paddingSize) - 1))+"").length
}
if (settings.padSingleDigit && paddingSize == 1) paddingSize = 2
if (settings.padTracks) return (num+"").padStart(paddingSize, "0")
return (num+"")
}
function generatePath(track, downloadObject, settings){
let filenameTemplate = "%artist% - %title%";
let singleTrack = false
if (downloadObject.type === "track"){
if (settings.createSingleFolder) filenameTemplate = settings.albumTracknameTemplate
else filenameTemplate = settings.tracknameTemplate
singleTrack = true
} else if (downloadObject.type === "album") {
filenameTemplate = settings.albumTracknameTemplate
} else {
filenameTemplate = settings.playlistTracknameTemplate
}
let filename = generateTrackName(filenameTemplate, track, settings)
let filepath, artistPath, coverPath, extrasPath
filepath = settings.downloadLocation || "."
if (settings.createPlaylistFolder && track.playlist && !settings.tags.savePlaylistAsCompilation)
filepath += `/${generatePlaylistName(settings.playlistNameTemplate, track.playlist, settings)}`
if (track.playlist && !settings.tags.savePlaylistAsCompilation)
extrasPath = filepath
if (
(settings.createArtistFolder && !track.playlist) ||
(settings.createArtistFolder && track.playlist && settings.tags.savePlaylistAsCompilation) ||
(settings.createArtistFolder && track.playlist && settings.createStructurePlaylist)
){
filepath += `/${generateArtistName(settings.artistNameTemplate, track.album.mainArtist, settings, track.album.rootArtist)}`
artistPath = filepath
}
if (settings.createAlbumFolder &&
(!singleTrack || singleTrack && settings.createSingleFolder) &&
(!track.playlist ||
(track.playlist && settings.tags.savePlaylistAsCompilation) ||
(track.playlist && settings.createStructurePlaylist)
)
){
filepath += `/${generateAlbumName(settings.albumNameTemplate, track.album, settings, track.playlist)}`
coverPath = filepath
}
if (!extrasPath) extrasPath = filepath
if (
parseInt(track.album.discTotal) > 1 && (
(settings.createAlbumFolder && settings.createCDFolder) &&
(!singleTrack || (singleTrack && settings.createSingleFolder)) &&
(!track.playlist ||
(track.playlist && settings.tags.savePlaylistAsCompilation)) ||
(track.playlist && settings.createStructurePlaylist)
)
)
filepath += `/CD${track.discNumber}`
// Remove Subfolders from filename and add it to filepath
if (filename.includes('/')){
let tempPath = filename.slice(0, filename.indexOf('/'))
filepath += `/${tempPath}`
filename = filename.slice(tempPath.length+1)
}
return {
filename,
filepath,
artistPath,
coverPath,
extrasPath
}
}
function generateTrackName(filename, track, settings){
let c = settings.illegalCharacterReplacer
filename = filename.replaceAll("%title%", fixName(track.title, c))
filename = filename.replaceAll("%artist%", fixName(track.mainArtist.name, c))
filename = filename.replaceAll("%artists%", fixName(track.artists.join(", "), c))
filename = filename.replaceAll("%tagsartists%", fixName(track.artistsString, c))
filename = filename.replaceAll("%allartists%", fixName(track.fullArtistsString, c))
filename = filename.replaceAll("%mainartists%", fixName(track.mainArtistsString, c))
if (track.featArtistsString) filename = filename.replaceAll("%featartists%", fixName('('+track.featArtistsString+')', c))
else filename = filename.replaceAll(" %featartists%", '').replaceAll("%featartists%", '')
filename = filename.replaceAll("%album%", fixName(track.album.title, c))
filename = filename.replaceAll("%albumartist%", fixName(track.album.mainArtist.name, c))
filename = filename.replaceAll("%tracknumber%", pad(track.trackNumber, track.album.trackTotal, settings))
filename = filename.replaceAll("%tracktotal%", track.album.trackTotal)
filename = filename.replaceAll("%discnumber%", track.discNumber)
filename = filename.replaceAll("%disctotal%", track.album.discTotal)
if (track.album.genre.length) filename = filename.replaceAll("%genre%", fixName(track.album.genre[0], c))
else filename = filename.replaceAll("%genre%", "Unknown")
filename = filename.replaceAll("%year%", track.date.year)
filename = filename.replaceAll("%date%", track.dateString)
filename = filename.replaceAll("%bpm%", track.bpm)
filename = filename.replaceAll("%label%", fixName(track.album.label, c))
filename = filename.replaceAll("%isrc%", track.ISRC)
filename = filename.replaceAll("%upc%", track.album.barcode)
if (track.explicit) filename = filename.replaceAll("%explicit%", "(Explicit)")
else filename = filename.replaceAll(" %explicit%", "").replaceAll("%explicit%", "")
filename = filename.replaceAll("%track_id%", track.id)
filename = filename.replaceAll("%album_id%", track.album.id)
filename = filename.replaceAll("%artist_id%", track.mainArtist.id)
if (track.playlist){
filename = filename.replaceAll("%playlist_id%", track.playlist.playlistID)
filename = filename.replaceAll("%position%", pad(track.position, track.playlist.trackTotal, settings))
} else {
filename = filename.replaceAll("%playlist_id%", '')
filename = filename.replaceAll("%position%", pad(track.trackNumber, track.album.trackTotal, settings))
}
filename = filename.replaceAll('\\', '/')
return antiDot(fixLongName(filename))
}
function generateAlbumName(foldername, album, settings, playlist){
let c = settings.illegalCharacterReplacer
if (playlist && settings.tags.savePlaylistAsCompilation){
foldername = foldername.replaceAll("%album_id%", "pl_" + playlist.playlistID)
foldername = foldername.replaceAll("%genre%", "Compile")
} else {
foldername = foldername.replaceAll("%album_id%", album.id)
if (album.genre.length) foldername = foldername.replaceAll("%genre%", fixName(album.genre[0], c))
else foldername = foldername.replaceAll("%genre%", "Unknown")
}
foldername = foldername.replaceAll("%album%", fixName(album.title, c))
foldername = foldername.replaceAll("%artist%", fixName(album.mainArtist.name, c))
foldername = foldername.replaceAll("%artists%", fixName(album.artists.join(", "), c))
foldername = foldername.replaceAll("%artist_id%", album.mainArtist.id)
if (album.rootArtist){
foldername = foldername.replaceAll("%root_artist%", fixName(album.rootArtist.name, c))
foldername = foldername.replaceAll("%root_artist_id%", album.rootArtist.id)
} else {
foldername = foldername.replaceAll("%root_artist%", fixName(album.mainArtist.name, c))
foldername = foldername.replaceAll("%root_artist_id%", album.mainArtist.id)
}
foldername = foldername.replaceAll("%tracktotal%", album.trackTotal)
foldername = foldername.replaceAll("%disctotal%", album.discTotal)
foldername = foldername.replaceAll("%type%", fixName(album.recordType.charAt(0).toUpperCase() + album.recordType.slice(1), c))
foldername = foldername.replaceAll("%upc%", album.barcode)
foldername = foldername.replaceAll("%explicit%", album.explicit ? "(Explicit)" : "")
foldername = foldername.replaceAll("%label%", fixName(album.label, c))
foldername = foldername.replaceAll("%year%", album.date.year)
foldername = foldername.replaceAll("%date%", album.dateString)
foldername = foldername.replaceAll("%bitrate%", bitrateLabels[parseInt(album.bitrate)])
foldername = foldername.replaceAll('\\', '/')
return antiDot(fixLongName(foldername))
}
function generateArtistName(foldername, artist, settings, rootArtist){
let c = settings['illegalCharacterReplacer']
foldername = foldername.replaceAll("%artist%", fixName(artist.name, c))
foldername = foldername.replaceAll("%artist_id%", artist.id)
if (rootArtist){
foldername = foldername.replaceAll("%root_artist%", fixName(rootArtist.name, c))
foldername = foldername.replaceAll("%root_artist_id%", rootArtist.id)
} else {
foldername = foldername.replaceAll("%root_artist%", fixName(artist.name, c))
foldername = foldername.replaceAll("%root_artist_id%", artist.id)
}
foldername = foldername.replaceAll('\\', '/')
return antiDot(fixLongName(foldername))
}
function generatePlaylistName(foldername, playlist, settings){
let c = settings['illegalCharacterReplacer']
let today = new Date()
let today_dz = new dzDate(String(today.getDate()).padStart(2, '0'), String(today.getMonth()+1).padStart(2, '0'), String(today.getFullYear()))
foldername = foldername.replaceAll("%playlist%", fixName(playlist.title, c))
foldername = foldername.replaceAll("%playlist_id%", fixName(playlist.playlistID, c))
foldername = foldername.replaceAll("%owner%", fixName(playlist.owner['name'], c))
foldername = foldername.replaceAll("%owner_id%", playlist.owner['id'])
foldername = foldername.replaceAll("%year%", playlist.date.year)
foldername = foldername.replaceAll("%date%", playlist.dateString)
foldername = foldername.replaceAll("%explicit%", playlist.explicit ? "(Explicit)" : "")
foldername = foldername.replaceAll("%today%", today_dz.format(settings['dateFormat']))
foldername = foldername.replaceAll('\\', '/')
return antiDot(fixLongName(foldername))
}
function generateDownloadObjectName(foldername, queueItem, settings){
let c = settings['illegalCharacterReplacer']
foldername = foldername.replaceAll("%title%", fixName(queueItem.title, c))
foldername = foldername.replaceAll("%artist%", fixName(queueItem.artist, c))
foldername = foldername.replaceAll("%size%", queueItem.size)
foldername = foldername.replaceAll("%type%", fixName(queueItem.type, c))
foldername = foldername.replaceAll("%id%", fixName(queueItem.id, c))
foldername = foldername.replaceAll("%bitrate%", bitrateLabels[parseInt(queueItem.bitrate)])
foldername = foldername.replaceAll('\\', '/').replace('/', c)
return antiDot(fixLongName(foldername))
}
module.exports = {
generatePath,
generateTrackName,
generateAlbumName,
generateArtistName,
generatePlaylistName,
generateDownloadObjectName
}

22
deemix/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "deemix",
"version": "3.6.15",
"description": "a barebones deezer downloader library",
"main": "deemix/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "RemixDev, Deeplydrumming",
"license": "GPL-3.0-or-later",
"dependencies": {
"async": "^3.2.0",
"deezer-js": "^1.3.0",
"got": "^11.8.2",
"html-entities": "^2.3.3",
"metaflac-js2": "^1.0.8",
"spotify-web-api-node": "../spotify-web-api-node"
},
"devDependencies": {
"eslint": "^9.7.0"
}
}