track metadata editing
This commit is contained in:
parent
a70cf0e508
commit
bcc708ded9
@ -362,6 +362,36 @@ canvas#visualizer {
|
|||||||
.sidebar a {
|
.sidebar a {
|
||||||
color: #18b9c1;
|
color: #18b9c1;
|
||||||
}
|
}
|
||||||
|
.modal {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: hsla(0, 0%, 1%, 0.5);
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 10%;
|
||||||
|
background-color: #152b3a;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 2em;
|
||||||
|
background-color: #1087bd;
|
||||||
|
border-bottom: 4px solid #1b6da5;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.modal-content label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.modal-content input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
tr td:nth-child(1), th:nth-child(1) {
|
tr td:nth-child(1), th:nth-child(1) {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -71,6 +71,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a class="ctx-item" data-action="play">Play Track</a></li>
|
<li><a class="ctx-item" data-action="play">Play Track</a></li>
|
||||||
<li><a class="ctx-item" data-action="queue">Queue Track</a></li>
|
<li><a class="ctx-item" data-action="queue">Queue Track</a></li>
|
||||||
|
<li><a class="ctx-item" data-action="edit">Edit Metadata</a></li>
|
||||||
<li><a class="ctx-item" data-action="download">Download</a></li>
|
<li><a class="ctx-item" data-action="download">Download</a></li>
|
||||||
<li class="ctx-multi"><a class="ctx-item playlist-add" style="display: none;">Add to Playlist</a>
|
<li class="ctx-multi"><a class="ctx-item playlist-add" style="display: none;">Add to Playlist</a>
|
||||||
<ul class="ctx-sub-items playlist-list" id="ctx-playlists"></ul>
|
<ul class="ctx-sub-items playlist-list" id="ctx-playlists"></ul>
|
||||||
@ -136,6 +137,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal" id="track-edit-modal" style="display: none;">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-header">Edit Track</div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<form id="track-set">
|
||||||
|
<label for="ts-title">Title</label>
|
||||||
|
<input type="text" name="ts-title" id="ts-title">
|
||||||
|
<label for="ts-artist">Artist</label>
|
||||||
|
<input type="text" name="ts-artist" id="ts-artist">
|
||||||
|
<label for="ts-album">Album</label>
|
||||||
|
<input type="text" name="ts-album" id="ts-album">
|
||||||
|
<label for="ts-genre">Genre</label>
|
||||||
|
<input type="text" name="ts-genre" id="ts-genre">
|
||||||
|
<label for="ts-year">Year</label>
|
||||||
|
<input type="text" name="ts-year" id="ts-year">
|
||||||
|
<label for="ts-track">Track nr</label>
|
||||||
|
<input type="text" name="ts-track" id="ts-track">
|
||||||
|
<input type="submit" value="Edit">
|
||||||
|
</form>
|
||||||
|
<button id="track-edit-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="text/javascript" src="index.js"></script>
|
<script type="text/javascript" src="index.js"></script>
|
||||||
<script type="text/javascript" src="player.js"></script>
|
<script type="text/javascript" src="player.js"></script>
|
||||||
<script type="text/javascript" src="visuals.js"></script>
|
<script type="text/javascript" src="visuals.js"></script>
|
||||||
|
@ -7,6 +7,9 @@
|
|||||||
var optdrop = document.getElementById('options-drop')
|
var optdrop = document.getElementById('options-drop')
|
||||||
var optmenu = document.getElementById('options')
|
var optmenu = document.getElementById('options')
|
||||||
var loggedin = document.getElementById('logged-in')
|
var loggedin = document.getElementById('logged-in')
|
||||||
|
var trackedit = document.getElementById('track-edit-modal')
|
||||||
|
var trackform = document.getElementById('track-set')
|
||||||
|
var trackclose = document.getElementById('track-edit-close')
|
||||||
|
|
||||||
var menu = document.getElementById('menu')
|
var menu = document.getElementById('menu')
|
||||||
|
|
||||||
@ -19,6 +22,7 @@
|
|||||||
<th class="small">Duration</th> \
|
<th class="small">Duration</th> \
|
||||||
</tr>'
|
</tr>'
|
||||||
|
|
||||||
|
var editing = null
|
||||||
var nowPlaying = 0
|
var nowPlaying = 0
|
||||||
var externalStream = false
|
var externalStream = false
|
||||||
|
|
||||||
@ -45,6 +49,7 @@
|
|||||||
var ctxPlaylist = document.getElementById('ctx-playlists')
|
var ctxPlaylist = document.getElementById('ctx-playlists')
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
|
var optcont = document.querySelector('.sidebar.bar')
|
||||||
var options = {
|
var options = {
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
trackids: true,
|
trackids: true,
|
||||||
@ -151,7 +156,8 @@
|
|||||||
return artist + ' - ' + title
|
return artist + ' - ' + title
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHash (hash) {
|
function handleHash () {
|
||||||
|
let hash = window.location.hash
|
||||||
if (hash.indexOf('#') === 0) hash = hash.substr(1)
|
if (hash.indexOf('#') === 0) hash = hash.substr(1)
|
||||||
if (hash.length === 0) return
|
if (hash.length === 0) return
|
||||||
|
|
||||||
@ -269,6 +275,25 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTrackModal () {
|
||||||
|
editing = null
|
||||||
|
trackedit.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTrack (tid) {
|
||||||
|
httpGet('/api/track/' + tid).then(function (metadata) {
|
||||||
|
editing = tid
|
||||||
|
for (let i in metadata) {
|
||||||
|
let el = trackform.querySelector('#ts-' + i)
|
||||||
|
if (!el) continue
|
||||||
|
el.value = metadata[i]
|
||||||
|
}
|
||||||
|
trackedit.style.display = 'block'
|
||||||
|
}, function (e) {
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ctxHandle (el) {
|
function ctxHandle (el) {
|
||||||
if (ctxState === 0) return
|
if (ctxState === 0) return
|
||||||
let dt = el.getAttribute("data-action")
|
let dt = el.getAttribute("data-action")
|
||||||
@ -285,6 +310,9 @@
|
|||||||
case 'download':
|
case 'download':
|
||||||
window.open('/api/serve/by-id/' + ctxState + '?dl=1', '_blank')
|
window.open('/api/serve/by-id/' + ctxState + '?dl=1', '_blank')
|
||||||
break
|
break
|
||||||
|
case 'edit':
|
||||||
|
editTrack(ctxState)
|
||||||
|
break
|
||||||
case 'playlist-remove':
|
case 'playlist-remove':
|
||||||
removeFromPlaylist(ctxState)
|
removeFromPlaylist(ctxState)
|
||||||
break
|
break
|
||||||
@ -311,6 +339,9 @@
|
|||||||
let plAdd = menu.querySelector('.playlist-add')
|
let plAdd = menu.querySelector('.playlist-add')
|
||||||
plAdd.style.display = isNaN(parseInt(xid)) ? 'none' : 'block'
|
plAdd.style.display = isNaN(parseInt(xid)) ? 'none' : 'block'
|
||||||
|
|
||||||
|
let tEdit = menu.querySelector('.ctx-item[data-action="edit"]')
|
||||||
|
tEdit.style.display = isNaN(parseInt(xid)) ? 'none' : 'block'
|
||||||
|
|
||||||
let plDel = menu.querySelector('.ctx-item[data-action="playlist-remove"]')
|
let plDel = menu.querySelector('.ctx-item[data-action="playlist-remove"]')
|
||||||
plDel.style.display = (playlist != null && playlist >= 0) ? 'block' : 'none'
|
plDel.style.display = (playlist != null && playlist >= 0) ? 'block' : 'none'
|
||||||
|
|
||||||
@ -368,7 +399,7 @@
|
|||||||
let tag = trackDataRow(track)
|
let tag = trackDataRow(track)
|
||||||
|
|
||||||
tag.addEventListener('click', function (e) {
|
tag.addEventListener('click', function (e) {
|
||||||
play(track.trackId || track.id)
|
play.call(this, track.trackId || track.id)
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
tag.addEventListener('contextmenu', function (e) {
|
tag.addEventListener('contextmenu', function (e) {
|
||||||
@ -737,6 +768,33 @@
|
|||||||
showTracks(pagePrev !== 0 ? pagePrev : 1)
|
showTracks(pagePrev !== 0 ? pagePrev : 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var metas = ['title', 'artist', 'album', 'year', 'genre', 'track']
|
||||||
|
trackform.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (editing == null) return closeTrackModal()
|
||||||
|
|
||||||
|
let meta = {}
|
||||||
|
|
||||||
|
for (let k in metas) {
|
||||||
|
let p = metas[k]
|
||||||
|
let a = trackedit.querySelector('#ts-' + p)
|
||||||
|
if (!a) continue
|
||||||
|
meta[p] = a.value
|
||||||
|
}
|
||||||
|
|
||||||
|
httpPost('/api/track/' + editing, meta).then(function () {
|
||||||
|
closeTrackModal()
|
||||||
|
alert('Successfully edited track metadata!')
|
||||||
|
if (!playlist || playlist < 0) showTracks(pageNum)
|
||||||
|
else showPlaylist(playlist)
|
||||||
|
}).catch(function (e) {
|
||||||
|
alert(e.message)
|
||||||
|
console.error(e)
|
||||||
|
})
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
trackclose.addEventListener('click', closeTrackModal, false)
|
||||||
|
|
||||||
document.getElementById('player-next').addEventListener('click', playNext, false)
|
document.getElementById('player-next').addEventListener('click', playNext, false)
|
||||||
|
|
||||||
document.getElementById('player-prev').addEventListener('click', playPrevious, false)
|
document.getElementById('player-prev').addEventListener('click', playPrevious, false)
|
||||||
@ -760,7 +818,7 @@
|
|||||||
|
|
||||||
window.addEventListener('hashchange', function (e) {
|
window.addEventListener('hashchange', function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleHash(window.location.hash)
|
handleHash()
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
window.addEventListener('resize', function (e) {
|
window.addEventListener('resize', function (e) {
|
||||||
@ -781,7 +839,6 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
var optcont = document.querySelector('.sidebar.bar')
|
|
||||||
document.addEventListener('click', function (event) {
|
document.addEventListener('click', function (event) {
|
||||||
// event.target.closest(optcont) === null
|
// event.target.closest(optcont) === null
|
||||||
if (!optcont.contains(event.target) && optdrop.className.indexOf('active') !== -1) {
|
if (!optcont.contains(event.target) && optdrop.className.indexOf('active') !== -1) {
|
||||||
@ -810,7 +867,7 @@
|
|||||||
checkUser()
|
checkUser()
|
||||||
loadOptions()
|
loadOptions()
|
||||||
showTracks(1)
|
showTracks(1)
|
||||||
handleHash(window.location.hash)
|
handleHash()
|
||||||
populatePlaylists()
|
populatePlaylists()
|
||||||
handleSelect()
|
handleSelect()
|
||||||
handleOptions()
|
handleOptions()
|
||||||
|
@ -2,8 +2,11 @@ import {exec} from 'child_process'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import url from 'url'
|
import url from 'url'
|
||||||
|
import os from 'os'
|
||||||
import qs from 'querystring'
|
import qs from 'querystring'
|
||||||
|
|
||||||
|
const supportedMetadata = ['album', 'genre', 'title', 'artist', 'year', 'track']
|
||||||
|
|
||||||
function filewalker (dir, done) {
|
function filewalker (dir, done) {
|
||||||
let results = []
|
let results = []
|
||||||
|
|
||||||
@ -50,6 +53,17 @@ async function insertDB (db, track) {
|
|||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateDB (db, id, meta) {
|
||||||
|
let sanit = []
|
||||||
|
for (let key in meta) {
|
||||||
|
if (supportedMetadata.indexOf(key) === -1) continue
|
||||||
|
let val = meta[key]
|
||||||
|
sanit.push(key + '="' + val + '"')
|
||||||
|
}
|
||||||
|
await db.run('UPDATE Track SET ' + sanit.join(',') + ' WHERE id = ?', id)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
function getFiles (dir) {
|
function getFiles (dir) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
filewalker(dir, (err, files) => {
|
filewalker(dir, (err, files) => {
|
||||||
@ -85,4 +99,77 @@ function copyAsync (fsrc, fdst) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {getFiles, promiseExec, askAsync, insertDB, copyAsync}
|
async function getInfos (file) {
|
||||||
|
let formatData = await promiseExec(`ffprobe -i "${file}" -show_entries format -v quiet -of json`)
|
||||||
|
|
||||||
|
let parsed = JSON.parse(formatData.stdout)
|
||||||
|
if (!parsed || !parsed.format || !parsed.format.duration) throw new Error('Failed to parse metadata!')
|
||||||
|
parsed = parsed.format
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
file,
|
||||||
|
duration: parseFloat(parsed.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.floor(data.duration) === 0) throw new Error('Invalid file type!')
|
||||||
|
|
||||||
|
if (parsed.tags) {
|
||||||
|
for (let k in parsed.tags) {
|
||||||
|
let tagtype = k.toLowerCase()
|
||||||
|
let value = parsed.tags[k]
|
||||||
|
|
||||||
|
if (tagtype === 'date') tagtype = 'year'
|
||||||
|
if (tagtype === 'track' || tagtype === 'year') {
|
||||||
|
if (value.indexOf('/') !== -1) {
|
||||||
|
value = value.split('/')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
value = parseInt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data[tagtype] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.title) {
|
||||||
|
let parsed = path.parse(file)
|
||||||
|
data.title = parsed.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMetadata (file, meta, source, cleanup = false) {
|
||||||
|
if (!meta || !meta['title'] || meta.title === '') throw new Error('Invalid metadata provided')
|
||||||
|
let sanit = []
|
||||||
|
let dbq = {}
|
||||||
|
for (let key in meta) {
|
||||||
|
if (supportedMetadata.indexOf(key) === -1) continue
|
||||||
|
let val = meta[key]
|
||||||
|
if ((key === 'artist' || key === 'title') && (!val || val === '')) continue
|
||||||
|
dbq[key] = val
|
||||||
|
if (key === 'year') key = 'date'
|
||||||
|
sanit.push('-metadata ' + key + '="' + val + '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanit.length) throw new Error('Invalid metadata provided')
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
let p = path.parse(file)
|
||||||
|
source = path.join(os.tmpdir(), '.retmp' + p.ext)
|
||||||
|
cleanup = true
|
||||||
|
await copyAsync(file, source)
|
||||||
|
await fs.unlink(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
await promiseExec(`ffmpeg -i "${source}" ${sanit.join(' ')} -codec copy "${file}"`)
|
||||||
|
|
||||||
|
if (cleanup) {
|
||||||
|
await fs.unlink(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file, dbq }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getFiles, promiseExec, askAsync, insertDB, updateDB,
|
||||||
|
copyAsync, getInfos, setMetadata, supportedMetadata }
|
||||||
|
@ -93,44 +93,4 @@ function fetchVideo (data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getInfos (file) {
|
export default { parseTitle, getVideoInfo, fetchVideo }
|
||||||
let formatData = await asn.promiseExec(`ffprobe -i "${file}" -show_entries format -v quiet -of json`)
|
|
||||||
|
|
||||||
let parsed = JSON.parse(formatData.stdout)
|
|
||||||
if (!parsed || !parsed.format || !parsed.format.duration) throw new Error('Failed to parse metadata!')
|
|
||||||
parsed = parsed.format
|
|
||||||
|
|
||||||
let data = {
|
|
||||||
file,
|
|
||||||
duration: parseFloat(parsed.duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.floor(data.duration) === 0) throw new Error('Invalid file type!')
|
|
||||||
|
|
||||||
if (parsed.tags) {
|
|
||||||
for (let k in parsed.tags) {
|
|
||||||
let tagtype = k.toLowerCase()
|
|
||||||
let value = parsed.tags[k]
|
|
||||||
|
|
||||||
if (tagtype === 'date') tagtype = 'year'
|
|
||||||
if (tagtype === 'track' || tagtype === 'year') {
|
|
||||||
if (value.indexOf('/') !== -1) {
|
|
||||||
value = value.split('/')[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
value = parseInt(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
data[tagtype] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.title) {
|
|
||||||
let parsed = path.parse(file)
|
|
||||||
data.title = parsed.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {parseTitle, getVideoInfo, fetchVideo, getInfos}
|
|
||||||
|
@ -51,7 +51,7 @@ async function handlePassed (db, fpath) {
|
|||||||
let trackinf
|
let trackinf
|
||||||
|
|
||||||
try {
|
try {
|
||||||
trackinf = await dl.getInfos(filePath)
|
trackinf = await asn.getInfos(filePath)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackinf = await interactive(filePath, db)
|
trackinf = await interactive(filePath, db)
|
||||||
}
|
}
|
||||||
@ -93,7 +93,7 @@ async function run () {
|
|||||||
// if (cleanTrackData.length > 10) break // debug purposes
|
// if (cleanTrackData.length > 10) break // debug purposes
|
||||||
process.stdout.write(`\rProcessing file ${parseInt(i) + 1} of ${files.length}.. (${skips} skipped)`)
|
process.stdout.write(`\rProcessing file ${parseInt(i) + 1} of ${files.length}.. (${skips} skipped)`)
|
||||||
try {
|
try {
|
||||||
let fd = await dl.getInfos(file)
|
let fd = await asn.getInfos(file)
|
||||||
cleanTrackData.push(fd)
|
cleanTrackData.push(fd)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
skips++
|
skips++
|
||||||
|
@ -49,15 +49,13 @@ async function download (furl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fn = path.join(musicdir, filename)
|
let fn = path.join(musicdir, filename)
|
||||||
|
await asn.setMetadata(fn, clean, file.source, true)
|
||||||
await asn.promiseExec(`ffmpeg -i "${file.source}" -metadata artist="${clean.artist}" -metadata title="${clean.title}" -codec copy "${fn}"`)
|
|
||||||
await fs.unlink(file.source)
|
|
||||||
|
|
||||||
let addAnsw = await asn.askAsync(rl, `Would you like to add it to the database now? [N/y] ? `)
|
let addAnsw = await asn.askAsync(rl, `Would you like to add it to the database now? [N/y] ? `)
|
||||||
if (addAnsw && addAnsw.trim().toLowerCase().indexOf('y') === 0) {
|
if (addAnsw && addAnsw.trim().toLowerCase().indexOf('y') === 0) {
|
||||||
// Add to database
|
// Add to database
|
||||||
try {
|
try {
|
||||||
let verify = await dl.getInfos(fn)
|
let verify = await asn.getInfos(fn)
|
||||||
await asn.insertDB(await dbPromise, verify)
|
await asn.insertDB(await dbPromise, verify)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('=!= Add to database failed!')
|
console.warn('=!= Add to database failed!')
|
||||||
|
@ -10,6 +10,8 @@ import ffmpeg from 'fluent-ffmpeg'
|
|||||||
import { user, userMiddleware } from './user'
|
import { user, userMiddleware } from './user'
|
||||||
import { dbPromise } from './database'
|
import { dbPromise } from './database'
|
||||||
|
|
||||||
|
import asn from './common/async'
|
||||||
|
|
||||||
import playlist from './playlist'
|
import playlist from './playlist'
|
||||||
import lastfm from './lastfm'
|
import lastfm from './lastfm'
|
||||||
|
|
||||||
@ -185,6 +187,20 @@ router.get('/track/:id', userMiddleware, async (req, res, next) => {
|
|||||||
res.jsonp(track)
|
res.jsonp(track)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/track/:id', userMiddleware, async (req, res, next) => {
|
||||||
|
let id = req.params.id
|
||||||
|
let meta = req.body
|
||||||
|
|
||||||
|
let db = await dbPromise
|
||||||
|
let track = await db.get('SELECT file FROM Track WHERE id = ?', id)
|
||||||
|
if (!track) throw new Error('404 track not found')
|
||||||
|
|
||||||
|
let m = await asn.setMetadata(track.file, meta)
|
||||||
|
await asn.updateDB(db, id, m.dbq)
|
||||||
|
|
||||||
|
res.jsonp(m)
|
||||||
|
})
|
||||||
|
|
||||||
// --------- //
|
// --------- //
|
||||||
// PLAYLISTS //
|
// PLAYLISTS //
|
||||||
// --------- //
|
// --------- //
|
||||||
|
Loading…
Reference in New Issue
Block a user