VulpesOS/gaia/apps/music/js/db.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

580 lines
17 KiB
JavaScript
Executable file

/* exported Database */
/* global AlbumArt, AudioMetadata, ForwardLock, IDBKeyRange, IntlHelper,
LazyLoader, MediaDB, Normalizer, service */
'use strict';
var Database = (function() {
var playlists = [
{
id: 'shuffle-all',
index: 'metadata.title',
direction: 'next',
shuffle: true
},
{
id: 'highest-rated',
index: 'metadata.rated',
direction: 'prev',
shuffle: false
},
{
id: 'recently-added',
index: 'date',
direction: 'prev',
shuffle: false
},
{
id: 'most-played',
index: 'metadata.played',
direction: 'prev',
shuffle: false
},
{
id: 'least-played',
index: 'metadata.played',
direction: 'next',
shuffle: false
}
];
var status = {
upgrading: false,
unavailable: false,
enumerable: false,
ready: false
};
var resolveEnumerable;
var enumerable = new Promise((resolve) => {
resolveEnumerable = resolve;
});
var resolveReady;
var ready = new Promise((resolve) => {
resolveReady = resolve;
});
var dbChange = debounce(() => service.broadcast('databaseChange'), 500);
document.addEventListener('DOMRetranslated', dbChange);
function debounce(fn, ms) {
var timeout;
return () => {
var args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), ms);
};
}
// The MediaDB object that manages the filesystem and the database of metadata
// See init()
var musicdb;
function init() {
// We want to exclude some folders that store ringtones so they don't show
// up in the music app. The regex matches absolute paths starting with a
// volume name (e.g. "/volume-name/Ringtones/") or relative paths starting
// with the excluded folder name (e.g. "Ringtones/").
var excludedFolders = ['Ringtones', 'Notifications', 'Alarms'];
var excludeFilter = new RegExp(
'^(/[^/]*/)?(' + excludedFolders.join('|') + ')/', 'i'
);
// Here we use the mediadb.js which gallery is using (in shared/js/)
// to index our music contents with metadata parsed.
// So the behaviors of musicdb are the same as the MediaDB in gallery
musicdb = new MediaDB('music', metadataParserWrapper, {
indexes: ['metadata.album', 'metadata.artist', 'metadata.title',
'metadata.rated', 'metadata.played', 'date'],
excludeFilter: excludeFilter,
batchSize: 1,
autoscan: false, // We call scan() explicitly after listing music we know
updateRecord: updateRecord,
reparsedRecord: reparsedRecord,
version: 3
});
function metadataParserWrapper(file, onsuccess, onerror) {
var scripts = [
'/js/metadata/metadata_scripts.js',
'/js/metadata/album_art.js'
];
LazyLoader.load(scripts).then(() => {
return AudioMetadata.parse(file);
}).then((metadata) => {
return AlbumArt.process(file, metadata);
}).then(onsuccess, onerror);
}
var startedReparsing = false;
function updateRecord(record, oldVersion, newVersion) {
if (oldVersion === 2) {
// Version 3 of the music DB changes ID3 parsing, so we need to reparse
// the file from scratch!
record.needsReparse = true;
if (!startedReparsing) {
startedReparsing = true;
service.broadcast('databaseUpgrade');
}
}
return record.metadata;
}
function reparsedRecord(oldMetadata, newMetadata) {
// We assume that updateRecord has already changed oldMetadata if
// necessary. (It's not necessary at the moment).
newMetadata.rated = oldMetadata.rated;
newMetadata.played = oldMetadata.played;
return newMetadata;
}
// show dialog in upgradestart, when it finished, it will turned to ready.
musicdb.onupgrading = function(event) {
status.upgrading = true;
status.enumerable = false;
status.ready = false;
service.broadcast('databaseUpgrade');
};
// This is called when DeviceStorage becomes unavailable because the
// sd card is removed or because it is mounted for USB mass storage
// This may be called before onready if it is unavailable to begin with
musicdb.onunavailable = function(event) {
var reason = event.detail === MediaDB.UNMOUNTED ?
'pluggedin' : event.detail;
onUnavailable(reason);
};
// If the user removed the sdcard (but there is still internal storage)
// we just need to stop playing, we don't have to put up an overlay.
// This event will be followed by deleted events to remove the songs
// that were on the sdcard and are no longer playable.
musicdb.oncardremoved = function() {
service.broadcast('databaseChange');
};
function onUnavailable(reason) {
status.unavailable = reason;
status.enumerable = false;
status.ready = false;
enumerable = new Promise((resolve) => {
resolveEnumerable = resolve;
});
ready = new Promise((resolve) => {
resolveReady = resolve;
});
service.broadcast('databaseUnavailable', reason);
}
musicdb.onenumerable = onEnumerable;
// Don't refresh the UI on the first onready event, since onenumerable will
// have already handled the refresh.
var refreshOnReady = false;
function onEnumerable() {
if (musicdb.state === MediaDB.READY) {
onReady();
} else {
musicdb.onready = onReady;
}
status.upgrading = false;
status.enumerable = true;
resolveEnumerable();
service.broadcast('databaseEnumerable');
}
function onReady() {
// Start scanning for new music
musicdb.scan();
status.unavailable = false;
status.enumerable = true;
status.ready = true;
resolveEnumerable();
resolveReady();
service.broadcast('databaseReady', refreshOnReady);
// Subsequent onready events need to refresh the UI.
refreshOnReady = true;
}
var filesDeletedWhileScanning = 0;
var filesFoundWhileScanning = 0;
var filesFoundBatch = 0;
var scanning = false;
var SCAN_UPDATE_BATCH_SIZE = 25; // Redisplay after this many new files
var DELETE_BATCH_TIMEOUT = 500; // Redisplay this long after a delete
var deleteTimer = null;
var firstScanDone = false;
// When musicdb scans, let the user know
musicdb.onscanstart = function() {
scanning = true;
filesFoundWhileScanning = 0;
filesFoundBatch = 0;
filesDeletedWhileScanning = 0;
};
// And hide the throbber when scanning is done
musicdb.onscanend = function() {
scanning = false;
service.broadcast('scanStopped');
if (filesFoundBatch > 0 || filesDeletedWhileScanning > 0) {
filesFoundWhileScanning = 0;
filesFoundBatch = 0;
filesDeletedWhileScanning = 0;
dbChange();
}
// If this was the first scan after startup, tell the performance monitors
// that we finished loading everything.
if (!firstScanDone) {
firstScanDone = true;
window.performance.mark('fullyLoaded');
}
};
// When MediaDB finds new files, it sends created events. During
// scanning we may get lots of them. Bluetooth file transfer can
// also result in created events. The way the app is currently
// structured, all we can do is rebuild the entire UI with the
// updated list of files. We don't want to do this for every new file
// but we do want to redisplay every so often.
musicdb.oncreated = function(event) {
if (scanning) {
var metadata = event.detail[0].metadata;
var n = event.detail.length;
filesFoundWhileScanning += n;
filesFoundBatch += n;
service.broadcast('scanProgress', {
count: filesFoundWhileScanning,
artist: metadata.artist,
title: metadata.title
});
if (filesFoundBatch > SCAN_UPDATE_BATCH_SIZE) {
filesFoundBatch = 0;
dbChange();
}
}
else {
// If we get a created event while we are not scanning, then
// there was probably a new song saved via bluetooth or MMS.
// We don't have any way to be clever about it; we just have to
// redisplay the entire view
dbChange();
}
};
// For deletions, we just set a flag and redisplay when the scan is done.
// This means that there is a longer window of time when the app might
// display music that is no longer available. But the only way to prevent
// this is to refuse to display any music until the scan completes.
musicdb.ondeleted = function(event) {
if (scanning) {
// If we get a deletion during a scan, just note it for processing
// when the scan is over
filesDeletedWhileScanning += event.detail.length;
}
else {
// Otherwise, if we're not scanning, this may be one in a series
// of deletions (we get lots when the sd card is pulled out, for
// example). Don't redisplay the UI right away. Instead, wait until the
// deletions seem to have stopped or paused before updating
if (deleteTimer) {
clearTimeout(deleteTimer);
}
deleteTimer = setTimeout(function() {
deleteTimer = null;
dbChange();
}, DELETE_BATCH_TIMEOUT);
}
};
}
function incrementPlayCount(fileinfo) {
return new Promise((resolve) => {
fileinfo.metadata.played++;
musicdb.updateMetadata(fileinfo.name, {
played: fileinfo.metadata.played
}, resolve);
});
}
function setSongRating(fileinfo, rated) {
return new Promise((resolve) => {
fileinfo.metadata.rated = rated;
musicdb.updateMetadata(fileinfo.name, {
rated: fileinfo.metadata.rated
}, resolve);
});
}
function getFile(fileinfo, decrypt = false) {
return new Promise((resolve, reject) => {
ready.then(() => {
musicdb.getFile(fileinfo.name, (file) => {
if (file) {
resolve(file);
} else {
reject('unable to get file: ' + fileinfo.name);
}
});
});
}).then((blob) => {
if (!decrypt || !fileinfo.metadata.locked) {
return blob;
}
return new Promise(function(resolve, reject) {
LazyLoader.load('/shared/js/omadrm/fl.js').then(() => {
ForwardLock.getKey(function(secret) {
ForwardLock.unlockBlob(secret, blob, resolve, null, reject);
});
});
});
});
}
function getFileInfo(filename) {
return new Promise(function(resolve, reject) {
ready
.then(() => musicdb.getFileInfo(
filename, (r) => {
r ? resolve(r) :
reject('Undefined result for ' + filename);
}, reject))
.catch((e) => reject(e));
});
}
/* [deprecated] Low-level database functions */
function enumerate(...args) {
return enumerable.then(() => musicdb.enumerate(...args));
}
function enumerateAll(...args) {
return enumerable.then(() => musicdb.enumerateAll(...args));
}
function advancedEnumerate(...args) {
return enumerable.then(() => musicdb.advancedEnumerate(...args));
}
function count(...args) {
return enumerable.then(() => musicdb.count(...args));
}
function cancelEnumeration(handle) {
musicdb.cancelEnumeration(handle);
}
/* Sorting helpers */
IntlHelper.define('titleSorter', 'collator', {
usage: 'sort', sensitivity: 'base', numeric: true, ignorePunctuation: true
});
function _localeSort(collator, a, b) {
return collator.compare(a, b);
}
function _sortArtist(collator, a, b) {
return _localeSort(collator, a.metadata.artist, b.metadata.artist);
}
function _sortAlbum(collator, a, b) {
return _localeSort(collator, a.metadata.album, b.metadata.album);
}
function _sortTitle(collator, a, b) {
return _localeSort(collator, a.metadata.title, b.metadata.title);
}
function _sortTrack(collator, a, b) {
return (a.metadata.discnum - b.metadata.discnum) ||
(a.metadata.tracknum - b.metadata.tracknum) ||
_sortTitle(collator, a, b);
}
/* High-level database functions */
/**
* Get the list of artists in the music library.
*
* @return {Promise} A Promise resolving to an array of artists (represented
* as fileinfos of a song from each artist).
*/
function artists() {
return new Promise((resolve) => {
enumerateAll('metadata.artist', null, 'nextunique', (artists) => {
artists.sort(_sortArtist.bind(null, IntlHelper.get('titleSorter')));
resolve(artists);
});
});
}
/**
* Get the list of albums in the music library.
*
* @return {Promise} A Promise resolving to an array of albums (represented
* as fileinfos of a song from each album).
*/
function albums() {
return new Promise((resolve) => {
enumerateAll('metadata.album', null, 'nextunique', (albums) => {
albums.sort(_sortAlbum.bind(null, IntlHelper.get('titleSorter')));
resolve(albums);
});
});
}
/**
* Get the list of songs in the music library.
*
* @return {Promise} A Promise resolving to an array of songs (represented
* as fileinfos).
*/
function songs() {
return new Promise((resolve) => {
enumerateAll('metadata.title', null, 'next', (songs) => {
songs.sort(_sortTitle.bind(null, IntlHelper.get('titleSorter')));
resolve(songs);
});
});
}
/**
* Get the total number of songs in the music library.
*
* @return {Promise} A Promise resolving to the number of songs in the
* database.
*/
function totalCount() {
return new Promise((resolve) => {
count('metadata.title', null, (count) => resolve(count));
});
}
/**
* Get all the songs for a given artist in the music library.
*
* @param {String} name The name of the artist to look up.
* @return {Promise} A Promise resolving to an array of songs for that artist
* (represented as fileinfos).
*/
function artist(name) {
return new Promise((resolve) => {
var range = IDBKeyRange.only(name);
enumerateAll('metadata.artist', range, 'next', (songs) => {
var collator = IntlHelper.get('titleSorter');
songs.sort((a, b) => {
return _sortAlbum(collator, a, b) || _sortTrack(collator, a, b);
});
resolve(songs);
});
});
}
/**
* Get all the songs for a given album in the music library.
*
* @param {String} name The name of the album to look up.
* @return {Promise} A Promise resolving to an array of songs for that album
* (represented as fileinfos).
*/
function album(name) {
return new Promise((resolve) => {
var range = IDBKeyRange.only(name);
enumerateAll('metadata.album', range, 'next', (songs) => {
songs.sort(_sortTrack.bind(null, IntlHelper.get('titleSorter')));
resolve(songs);
});
});
}
/**
* Search the music database for a particular query.
*
* @param {String} key The field to query against (e.g. 'title').
* @param {String} query The string to search for.
* @param {Function} callback A function to call with the results of the
* search, called once per result (plus a final call passing `null`).
* @return {Object} The handle for this query.
*/
function search(key, query, callback) {
return enumerable.then(() => {
if (!query) {
callback(null);
return;
}
return LazyLoader.load('/shared/js/text_normalizer.js').then(() => {
// Convert to lowercase and replace accented characters.
query = Normalizer.toAscii(query.toLocaleLowerCase());
var direction = (key === 'title') ? 'next' : 'nextunique';
return musicdb.enumerate('metadata.' + key, null, direction,
(result) => {
if (result === null) {
callback(result);
return;
}
var resultLowerCased = result.metadata[key].toLocaleLowerCase();
if (Normalizer.toAscii(resultLowerCased).indexOf(query) !== -1) {
callback(result);
}
});
});
});
}
return {
init: init,
incrementPlayCount: incrementPlayCount,
setSongRating: setSongRating,
getFile: getFile,
getFileInfo: getFileInfo,
enumerate: enumerate,
enumerateAll: enumerateAll,
advancedEnumerate: advancedEnumerate,
count: count,
cancelEnumeration: cancelEnumeration,
artists: artists,
albums: albums,
songs: songs,
totalCount: totalCount,
artist: artist,
album: album,
search: search,
playlists: playlists,
status: status,
// This is just here for testing.
get initialScanComplete() {
return musicdb.initialScanComplete;
}
};
})();