2344 lines
79 KiB
JavaScript
Executable File
2344 lines
79 KiB
JavaScript
Executable File
import ReactGA from 'react-ga';
|
|
import Mopidy from 'mopidy';
|
|
import { sha256 } from 'js-sha256';
|
|
import { sampleSize, compact, chunk, find, uniq } from 'lodash';
|
|
import { i18n } from '../../locale';
|
|
import {
|
|
generateGuid,
|
|
uriSource,
|
|
setFavicon,
|
|
titleCase,
|
|
} from '../../util/helpers';
|
|
import {
|
|
digestMopidyImages,
|
|
formatImages,
|
|
formatAlbum,
|
|
formatTrack,
|
|
formatTracks,
|
|
formatPlaylistGroup,
|
|
getTrackIcon,
|
|
formatArtists,
|
|
formatArtist,
|
|
formatPlaylist,
|
|
injectSortId,
|
|
formatSimpleObject,
|
|
} from '../../util/format';
|
|
import {
|
|
arrayOf,
|
|
shuffle,
|
|
applyFilter,
|
|
sortItems,
|
|
indexToArray,
|
|
} from '../../util/arrays';
|
|
import { getProvider, getSortSelector } from '../../util/selectors';
|
|
|
|
const mopidyActions = require('./actions.js');
|
|
const coreActions = require('../core/actions.js');
|
|
const uiActions = require('../ui/actions.js');
|
|
const spotifyActions = require('../spotify/actions.js');
|
|
const pusherActions = require('../pusher/actions.js');
|
|
const googleActions = require('../google/actions.js');
|
|
const lastfmActions = require('../lastfm/actions.js');
|
|
const geniusActions = require('../genius/actions.js');
|
|
const discogsActions = require('../discogs/actions.js');
|
|
|
|
/**
|
|
* Fetch the method of our Mopidy API that is being called, by string
|
|
*
|
|
* @param {String} call
|
|
* @param {Object} socket
|
|
*/
|
|
const getController = (call, socket) => {
|
|
const callParts = call.split('.');
|
|
const model = callParts[0];
|
|
const method = callParts[1];
|
|
|
|
let controller = null;
|
|
if (socket && socket[model]) {
|
|
if (method in socket[model] && socket[model][method]) {
|
|
controller = socket[model][method];
|
|
} else {
|
|
controller = socket[model];
|
|
}
|
|
}
|
|
|
|
return controller;
|
|
};
|
|
|
|
const MopidyMiddleware = (function () {
|
|
// container for the actual Mopidy socket
|
|
let socket = null;
|
|
|
|
// play position timer
|
|
let progress_interval = null;
|
|
|
|
// handle all manner of socket messages
|
|
const handleMessage = (ws, store, type, data) => {
|
|
// if debug enabled
|
|
if (store.getState().ui.log_mopidy) {
|
|
console.log('Mopidy', type, data);
|
|
}
|
|
|
|
switch (type) {
|
|
case 'state:online':
|
|
store.dispatch({ type: 'MOPIDY_CONNECTED' });
|
|
|
|
store.dispatch(mopidyActions.getCurrentTrack());
|
|
store.dispatch(mopidyActions.getPlayState());
|
|
store.dispatch(mopidyActions.getVolume());
|
|
store.dispatch(mopidyActions.getMute());
|
|
store.dispatch(mopidyActions.getConsume());
|
|
store.dispatch(mopidyActions.getRandom());
|
|
store.dispatch(mopidyActions.getRepeat());
|
|
store.dispatch(mopidyActions.getQueue());
|
|
store.dispatch(mopidyActions.getTimePosition());
|
|
store.dispatch(mopidyActions.getUriSchemes());
|
|
store.dispatch(mopidyActions.getStreamTitle());
|
|
|
|
// Every 10s update our play position (when playing)
|
|
progress_interval = setInterval(() => {
|
|
if (
|
|
store.getState().mopidy.play_state === 'playing'
|
|
&& store.getState().ui.window_focus === true
|
|
) {
|
|
store.dispatch(mopidyActions.getTimePosition());
|
|
}
|
|
}, 10000);
|
|
|
|
break;
|
|
|
|
case 'state:offline':
|
|
store.dispatch({ type: 'MOPIDY_DISCONNECTED' });
|
|
store.dispatch(mopidyActions.clearCurrentTrack());
|
|
|
|
// reset our playback interval timer
|
|
clearInterval(progress_interval);
|
|
break;
|
|
|
|
case 'event:tracklistChanged':
|
|
store.dispatch(mopidyActions.getQueue());
|
|
|
|
// Wait a jiffy before we get the next track
|
|
// We don't want to impede snappyness for this luxury request
|
|
setTimeout(
|
|
() => {
|
|
store.dispatch(mopidyActions.getNextTrack());
|
|
},
|
|
1000,
|
|
);
|
|
break;
|
|
|
|
case 'event:playbackStateChanged':
|
|
store.dispatch({
|
|
type: 'MOPIDY_PLAY_STATE',
|
|
play_state: data.new_state,
|
|
});
|
|
store.dispatch(mopidyActions.getTimePosition());
|
|
break;
|
|
|
|
case 'event:seeked':
|
|
store.dispatch({
|
|
type: 'MOPIDY_TIME_POSITION',
|
|
time_position: data.time_position,
|
|
});
|
|
break;
|
|
|
|
case 'event:trackPlaybackEnded':
|
|
store.dispatch(mopidyActions.clearCurrentTrack());
|
|
store.dispatch({
|
|
type: 'MOPIDY_TIME_POSITION',
|
|
time_position: 0,
|
|
});
|
|
break;
|
|
|
|
case 'event:trackPlaybackStarted':
|
|
if (data.tl_track) {
|
|
store.dispatch(mopidyActions.currentTrackLoaded(data.tl_track));
|
|
|
|
// Wait a jiffy before we get the next track
|
|
// We don't want to impede snappyness for this luxury request
|
|
setTimeout(
|
|
() => {
|
|
store.dispatch(mopidyActions.getNextTrack());
|
|
},
|
|
1000,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'event:volumeChanged':
|
|
store.dispatch({ type: 'MOPIDY_VOLUME', volume: data.volume });
|
|
break;
|
|
|
|
case 'event:muteChanged':
|
|
store.dispatch({ type: 'MOPIDY_MUTE', mute: data.mute });
|
|
break;
|
|
|
|
case 'event:optionsChanged':
|
|
store.dispatch(mopidyActions.getConsume());
|
|
store.dispatch(mopidyActions.getRandom());
|
|
store.dispatch(mopidyActions.getRepeat());
|
|
break;
|
|
|
|
case 'event:streamTitleChanged':
|
|
store.dispatch(coreActions.streamTitleChanged(data.title));
|
|
break;
|
|
|
|
default:
|
|
// console.log('MopidyService: Unhandled message', type, message );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Call something with Mopidy
|
|
*
|
|
* Sends request to Mopidy server, and updates our local storage on return
|
|
* @param string model = which Mopidy model (playback, tracklist, etc)
|
|
* @param string property = TlTracks, Consume, etc
|
|
* @param string value (optional) = value of the property to pass
|
|
* @return promise
|
|
* */
|
|
const request = (store, call, value = {}) => new Promise((resolve, reject) => {
|
|
const loaderId = generateGuid();
|
|
const loaderKey = `mopidy_${call}_${value?.uri || ''}_${value?.uris?.join('_') || ''}`;
|
|
store.dispatch(uiActions.startLoading(loaderId, loaderKey));
|
|
|
|
const doRequest = () => {
|
|
const controller = getController(call, socket);
|
|
|
|
if (controller) {
|
|
const timeout = setTimeout(
|
|
() => {
|
|
store.dispatch(uiActions.stopLoading(loaderId));
|
|
reject(new Error('Request timed out'));
|
|
},
|
|
30000,
|
|
);
|
|
|
|
controller(value)
|
|
.then(
|
|
(response) => {
|
|
clearTimeout(timeout);
|
|
store.dispatch(uiActions.stopLoading(loaderId));
|
|
resolve(response);
|
|
},
|
|
(error) => {
|
|
clearTimeout(timeout);
|
|
store.dispatch(uiActions.stopLoading(loaderId));
|
|
reject(error);
|
|
},
|
|
);
|
|
} else {
|
|
store.dispatch(uiActions.stopLoading(loaderId));
|
|
console.warn(
|
|
'Mopidy request aborted. Either Mopidy is not connected or the request method is invalid. Check the request and your server settings.',
|
|
{
|
|
call, value, socket, controller,
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
// Give a 5-second leeway for allowing Mopidy to connect, if it isn't already connected
|
|
if (socket && store.getState().mopidy.connected) {
|
|
doRequest();
|
|
} else {
|
|
console.info('Mopidy not yet connected, waiting 2 seconds');
|
|
setTimeout(
|
|
() => doRequest(),
|
|
2000,
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Process our search queries queue. We process one item in the queue and then
|
|
* reiterate if there are any items remaining.
|
|
*
|
|
* @param {Object} store
|
|
* @param {Array} queries
|
|
*/
|
|
const processSearchQueue = (store, queue) => {
|
|
const {
|
|
type,
|
|
term,
|
|
requestType,
|
|
uri_scheme,
|
|
method = 'library.search',
|
|
data,
|
|
} = queue.shift();
|
|
const processKey = 'MOPIDY_GET_SEARCH_RESULTS';
|
|
const processor = store.getState().ui.processes[processKey];
|
|
|
|
if (processor && processor.status === 'cancelling') {
|
|
store.dispatch(uiActions.processCancelled('MOPIDY_GET_SEARCH_RESULTS'));
|
|
return;
|
|
}
|
|
|
|
store.dispatch(uiActions.updateProcess(
|
|
processKey,
|
|
{
|
|
content: i18n(
|
|
'services.mopidy.searching',
|
|
{
|
|
provider: titleCase(uri_scheme.replace(':', '')),
|
|
type: requestType,
|
|
},
|
|
),
|
|
remaining: queue.length,
|
|
},
|
|
));
|
|
|
|
// Each type has a different method of formatting and destructuring.
|
|
const processResults = {
|
|
albums: (response) => {
|
|
const result = response[0];
|
|
const albums = {};
|
|
if (result.albums) {
|
|
result.albums.forEach((album) => {
|
|
if (album.uri) {
|
|
albums[album.uri] = {
|
|
...albums[album.uri],
|
|
...formatAlbum(album),
|
|
};
|
|
}
|
|
});
|
|
}
|
|
if (result.tracks) {
|
|
result.tracks.forEach((track) => {
|
|
if (track.album && track.album.uri) {
|
|
albums[track.album.uri] = {
|
|
...albums[track.album.uri],
|
|
...formatAlbum(track.album),
|
|
};
|
|
}
|
|
});
|
|
}
|
|
return indexToArray(albums);
|
|
},
|
|
artists: (response) => {
|
|
const result = response[0];
|
|
const artists = {};
|
|
if (result.artists) {
|
|
result.artists.forEach((artist) => {
|
|
if (artist.uri) {
|
|
artists[artist.uri] = {
|
|
...artists[artist.uri],
|
|
...formatArtist(artist),
|
|
};
|
|
}
|
|
});
|
|
}
|
|
if (result.tracks) {
|
|
result.tracks.forEach((track) => {
|
|
if (track.artists) {
|
|
track.artists.forEach((artist) => {
|
|
if (artist && artist.uri) {
|
|
artists[artist.uri] = {
|
|
...artists[artist.uri],
|
|
...formatArtist(artist),
|
|
};
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
return indexToArray(artists);
|
|
},
|
|
playlists: (response) => {
|
|
const playlists = response.filter(
|
|
(item) => {
|
|
if (!item.uri.includes(uri_scheme)) return false;
|
|
return item.name.toLowerCase().includes(term.toLowerCase());
|
|
},
|
|
);
|
|
return playlists.map((playlist) => ({
|
|
...formatPlaylist(playlist),
|
|
uri: playlist.uri,
|
|
}));
|
|
},
|
|
tracks: (response) => {
|
|
const { tracks = [] } = response[0];
|
|
return formatTracks(tracks);
|
|
},
|
|
};
|
|
|
|
request(
|
|
store,
|
|
method,
|
|
data,
|
|
).then(
|
|
(response) => {
|
|
if (response.length > 0) {
|
|
store.dispatch(coreActions.searchResultsLoaded(
|
|
{ term, type },
|
|
requestType,
|
|
processResults[requestType](response),
|
|
));
|
|
}
|
|
|
|
if (queue.length) {
|
|
processSearchQueue(store, queue);
|
|
} else {
|
|
store.dispatch(uiActions.processFinished(processKey));
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Middleware
|
|
*
|
|
* This behaves like an action interceptor. We listen for specific actions
|
|
* and handle special functionality. If the action is not in our switch, then
|
|
* it just proceeds to the next middleware, or default functionality
|
|
* */
|
|
return (store) => (next) => (action) => {
|
|
switch (action.type) {
|
|
case 'MOPIDY_CONNECT': {
|
|
if (socket != null) socket.close();
|
|
|
|
store.dispatch({ type: 'MOPIDY_CONNECTING' });
|
|
const { ssl, host, port } = store.getState().mopidy;
|
|
try {
|
|
socket = new Mopidy({
|
|
webSocketUrl: `ws${ssl ? 's' : ''}://${host}:${port}/mopidy/ws/`,
|
|
callingConvention: 'by-position-or-by-name',
|
|
});
|
|
} catch (exception) {
|
|
console.error(exception);
|
|
break;
|
|
}
|
|
|
|
socket.on('state:online', (data) => handleMessage(socket, store, 'state:online', data));
|
|
socket.on('state:offline', (data) => handleMessage(socket, store, 'state:offline', data));
|
|
socket.on('event:tracklistChanged', (data) => handleMessage(socket, store, 'event:tracklistChanged', data));
|
|
socket.on('event:playbackStateChanged', (data) => handleMessage(socket, store, 'event:playbackStateChanged', data));
|
|
socket.on('event:seeked', (data) => handleMessage(socket, store, 'event:seeked', data));
|
|
socket.on('event:trackPlaybackEnded', (data) => handleMessage(socket, store, 'event:trackPlaybackEnded', data));
|
|
socket.on('event:trackPlaybackStarted', (data) => handleMessage(socket, store, 'event:trackPlaybackStarted', data));
|
|
socket.on('event:volumeChanged', (data) => handleMessage(socket, store, 'event:volumeChanged', data));
|
|
socket.on('event:muteChanged', (data) => handleMessage(socket, store, 'event:muteChanged', data));
|
|
socket.on('event:optionsChanged', (data) => handleMessage(socket, store, 'event:optionsChanged', data));
|
|
socket.on('event:streamTitleChanged', (data) => handleMessage(socket, store, 'event:streamTitleChanged', data));
|
|
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_CONNECTED':
|
|
if (store.getState().ui.allow_reporting) {
|
|
ReactGA.event({
|
|
category: 'Mopidy',
|
|
action: 'Connected',
|
|
label: sha256(window.location.hostname),
|
|
});
|
|
}
|
|
next(action);
|
|
break;
|
|
|
|
case 'MOPIDY_DISCONNECT':
|
|
if (socket != null) socket.close();
|
|
socket = null;
|
|
store.dispatch({ type: 'MOPIDY_DISCONNECTED' });
|
|
break;
|
|
|
|
case 'MOPIDY_DISCONNECTED':
|
|
setFavicon('favicon_error.png');
|
|
break;
|
|
|
|
case 'MOPIDY_DEBUG':
|
|
request(store, action.call, action.value)
|
|
.then((response) => {
|
|
store.dispatch({ type: 'DEBUG', response });
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_UPDATE_SERVER':
|
|
const { servers } = store.getState().mopidy;
|
|
const server = {
|
|
...servers[action.server.id] || {},
|
|
...action.server,
|
|
};
|
|
server.url = `http${server.ssl ? 's' : ''}://${server.host}:${server.port}`;
|
|
store.dispatch(mopidyActions.updateServers({ ...servers, [server.id]: server }));
|
|
break;
|
|
|
|
case 'MOPIDY_SET_CURRENT_SERVER': {
|
|
const { server } = action;
|
|
const { servers } = store.getState().mopidy;
|
|
let existingServer = null;
|
|
if (action.server.id) {
|
|
existingServer = servers[action.server.id] || undefined;
|
|
} else {
|
|
// No ID provided, see if we have a server setup with the same details already
|
|
existingServer = find(servers, (s) => s.url === server.url);
|
|
}
|
|
|
|
if (!existingServer) {
|
|
const create = mopidyActions.addServer(server);
|
|
store.dispatch(create);
|
|
existingServer = create.server;
|
|
}
|
|
|
|
store.dispatch(mopidyActions.set({
|
|
current_server: server.id || existingServer?.id,
|
|
host: server.host,
|
|
port: server.port,
|
|
ssl: server.ssl,
|
|
connected: false,
|
|
connecting: false,
|
|
}));
|
|
|
|
// Wait a moment for store to update, then attempt connection
|
|
setTimeout(
|
|
() => {
|
|
store.dispatch(mopidyActions.connect());
|
|
store.dispatch(pusherActions.connect());
|
|
},
|
|
250,
|
|
);
|
|
next(action);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_SERVER_STATE': {
|
|
let server = { ...store.getState().mopidy.servers[action.id] };
|
|
if (!server.host || !server.port || server.host === '' || server.port === '') break;
|
|
|
|
const host = `http${server.ssl ? 's' : ''}://${server.host}:${server.port}`;
|
|
fetch(`${host}/iris/http/get_server_state`)
|
|
.then((response) => response.json())
|
|
.then(({ result }) => {
|
|
server = { ...server, ...result };
|
|
if (result.current_track) {
|
|
const images = result.current_track.images
|
|
? formatImages(digestMopidyImages(server, result.current_track.images))
|
|
: null;
|
|
server.current_track = formatTrack({ ...result.current_track, images });
|
|
}
|
|
store.dispatch(mopidyActions.updateServer(server));
|
|
})
|
|
.catch((error) => {
|
|
store.dispatch(coreActions.handleException('Could not fetch server', error, host, false));
|
|
});
|
|
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_REMOVE_SERVER': {
|
|
const servers = { ...store.getState().mopidy.servers };
|
|
delete servers[action.id];
|
|
store.dispatch(mopidyActions.updateServers(servers));
|
|
break;
|
|
}
|
|
|
|
case 'SET_WINDOW_FOCUS':
|
|
/**
|
|
* Disabled because it causes so many requests without any conscious user input.
|
|
* Will monitor to see if it has any adverse impact.
|
|
*/
|
|
|
|
/*
|
|
// Focus has just been regained
|
|
if (action.window_focus === true) {
|
|
store.dispatch(mopidyActions.getCurrentTrack());
|
|
store.dispatch(mopidyActions.getStreamTitle());
|
|
store.dispatch(mopidyActions.getPlayState());
|
|
store.dispatch(mopidyActions.getVolume());
|
|
store.dispatch(mopidyActions.getMute());
|
|
store.dispatch(mopidyActions.getConsume());
|
|
store.dispatch(mopidyActions.getRandom());
|
|
store.dispatch(mopidyActions.getRepeat());
|
|
store.dispatch(mopidyActions.getTimePosition());
|
|
}
|
|
*/
|
|
next(action);
|
|
break;
|
|
|
|
case 'MOPIDY_REQUEST':
|
|
request(store, action.method, action.params)
|
|
.then(
|
|
(response) => {
|
|
if (action.response_callback) {
|
|
action.response_callback(response);
|
|
}
|
|
},
|
|
(error) => {
|
|
if (action.error_callback) {
|
|
action.error_callback(error);
|
|
} else {
|
|
store.dispatch(coreActions.handleException(
|
|
'Mopidy request failed',
|
|
error,
|
|
action.method,
|
|
action,
|
|
));
|
|
}
|
|
},
|
|
);
|
|
break;
|
|
|
|
/**
|
|
* General playback
|
|
* */
|
|
|
|
case 'MOPIDY_PLAY_STATE':
|
|
store.dispatch(uiActions.setWindowTitle(null, action.play_state));
|
|
|
|
if (action.play_state == 'playing') {
|
|
setFavicon('favicon.png');
|
|
} else {
|
|
setFavicon('favicon_paused.png');
|
|
}
|
|
next(action);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_PLAY_STATE':
|
|
request(store, 'playback.getState')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_PLAY_STATE',
|
|
play_state: response,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_PLAY':
|
|
request(store, 'playback.play')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_PLAY_STATE',
|
|
play_state: 'playing',
|
|
});
|
|
},
|
|
);
|
|
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username + (store.getState().mopidy.play_state == 'paused' ? ' resumed' : ' started')} playback`,
|
|
icon: (store.getState().core.current_track ? getTrackIcon(store.getState().core.current_track, store.getState().core) : false),
|
|
},
|
|
},
|
|
));
|
|
break;
|
|
|
|
case 'MOPIDY_PAUSE':
|
|
request(store, 'playback.pause')
|
|
.then((response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_PLAY_STATE',
|
|
play_state: 'paused',
|
|
});
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} paused playback`,
|
|
icon: (store.getState().core.current_track ? getTrackIcon(store.getState().core.current_track, store.getState().core) : false),
|
|
},
|
|
},
|
|
));
|
|
});
|
|
|
|
break;
|
|
|
|
case 'MOPIDY_PREVIOUS':
|
|
|
|
// Let the UI know we're in transition
|
|
store.dispatch(uiActions.setCurrentTrackTransition('previous'));
|
|
|
|
request(store, 'playback.previous');
|
|
break;
|
|
|
|
case 'MOPIDY_NEXT':
|
|
|
|
// Let the UI know we're in transition
|
|
store.dispatch(uiActions.setCurrentTrackTransition('next'));
|
|
|
|
request(store, 'playback.next')
|
|
.then((response) => {
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} skipped "${store.getState().core.current_track.name}"`,
|
|
icon: (store.getState().core.current_track ? getTrackIcon(store.getState().core.current_track, store.getState().core) : false),
|
|
},
|
|
},
|
|
));
|
|
});
|
|
|
|
break;
|
|
|
|
case 'MOPIDY_STOP':
|
|
request(store, 'playback.stop')
|
|
.then((response) => {
|
|
store.dispatch(mopidyActions.clearCurrentTrack());
|
|
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} stopped playback`,
|
|
icon: (store.getState().core.current_track ? getTrackIcon(store.getState().core.current_track, store.getState().core) : false),
|
|
},
|
|
},
|
|
));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_CHANGE_TRACK':
|
|
request(store, 'playback.play', { tlid: action.tlid })
|
|
.then((response) => {
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} changed track`,
|
|
},
|
|
},
|
|
));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_REMOVE_TRACKS':
|
|
request(store, 'tracklist.remove', { criteria: { tlid: action.tlids } })
|
|
.then((response) => {
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} removed ${action.tlids.length} tracks`,
|
|
},
|
|
},
|
|
));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_GET_REPEAT':
|
|
request(store, 'tracklist.getRepeat')
|
|
.then((response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_REPEAT',
|
|
repeat: response,
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_SET_REPEAT':
|
|
request(store, 'tracklist.setRepeat', [action.repeat]);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_RANDOM':
|
|
request(store, 'tracklist.getRandom')
|
|
.then((response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_RANDOM',
|
|
random: response,
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_SET_RANDOM':
|
|
request(store, 'tracklist.setRandom', [action.random]);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_CONSUME':
|
|
request(store, 'tracklist.getConsume')
|
|
.then((response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_CONSUME',
|
|
consume: response,
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_SET_CONSUME':
|
|
request(store, 'tracklist.setConsume', [action.consume]);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_MUTE':
|
|
request(store, 'mixer.getMute')
|
|
.then((response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_MUTE',
|
|
mute: response,
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_SET_MUTE':
|
|
request(store, 'mixer.setMute', [action.mute])
|
|
.then((response) => {
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username + (action.mute ? ' muted' : ' unmuted')} playback`,
|
|
},
|
|
},
|
|
));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_GET_VOLUME':
|
|
request(store, 'mixer.getVolume')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_VOLUME',
|
|
volume: response,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_SET_VOLUME':
|
|
request(store, 'mixer.setVolume', { volume: action.volume })
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_VOLUME',
|
|
volume: action.volume,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_SET_TIME_POSITION':
|
|
request(store, 'playback.seek', { time_position: action.time_position })
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_TIME_POSITION',
|
|
time_position: action.time_position,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_TIME_POSITION':
|
|
request(store, 'playback.getTimePosition')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_TIME_POSITION',
|
|
time_position: response,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_URI_SCHEMES':
|
|
request(store, 'getUriSchemes')
|
|
.then(
|
|
(response) => {
|
|
// Remove unsupported schemes
|
|
const remove = ['http', 'https', 'mms', 'rtmp', 'rtmps', 'rtsp', 'sc', 'yt'];
|
|
let uri_schemes = response.filter((uri) => remove.indexOf(uri) === -1);
|
|
|
|
// Append with ':' to make them a mopidy URI
|
|
uri_schemes = uri_schemes.map((uri) => `${uri}:`);
|
|
|
|
// Enable Iris providers when the backend is available
|
|
store.dispatch(spotifyActions.set({ enabled: uri_schemes.includes('spotify:') }));
|
|
store.dispatch(googleActions.set({ enabled: uri_schemes.includes('gmusic:') }));
|
|
|
|
// If we haven't customised our search schemes, add all to search
|
|
if (store.getState().ui.uri_schemes_search_enabled === undefined) {
|
|
store.dispatch(uiActions.set({ uri_schemes_search_enabled: uri_schemes }));
|
|
}
|
|
|
|
store.dispatch({ type: 'MOPIDY_URI_SCHEMES', uri_schemes });
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_PLAY_PLAYLIST': {
|
|
const playlist = store.getState().core.items[action.uri];
|
|
const { sortField, sortReverse } = getSortSelector(store.getState(), 'playlist_tracks');
|
|
if (playlist && playlist.tracks) {
|
|
store.dispatch(
|
|
mopidyActions.playURIs({
|
|
uris: arrayOf(
|
|
'uri',
|
|
sortItems(
|
|
playlist.tracks.filter((t) => t?.is_playable !== false),
|
|
sortField,
|
|
sortReverse,
|
|
),
|
|
),
|
|
from: {
|
|
uri: playlist?.uri,
|
|
name: playlist?.name,
|
|
type: 'playlist',
|
|
},
|
|
...action,
|
|
}),
|
|
);
|
|
break;
|
|
}
|
|
store.dispatch(
|
|
coreActions.loadPlaylist(
|
|
action.uri,
|
|
{ full: true, callbackAction: { name: 'play', shuffle: action.shuffle } },
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_PLAY_ALBUM': {
|
|
const album = store.getState().core.items[action.uri];
|
|
const { sortField, sortReverse } = getSortSelector(store.getState(), 'playlist_tracks');
|
|
if (album && album.tracks) {
|
|
store.dispatch(
|
|
mopidyActions.playURIs({
|
|
uris: arrayOf(
|
|
'uri',
|
|
sortItems(
|
|
album.tracks.filter((t) => t?.is_playable !== false),
|
|
sortField,
|
|
sortReverse,
|
|
),
|
|
),
|
|
from: {
|
|
uri: album?.uri,
|
|
name: album?.name,
|
|
type: 'album',
|
|
},
|
|
...action,
|
|
}),
|
|
);
|
|
break;
|
|
}
|
|
store.dispatch(
|
|
coreActions.loadAlbum(
|
|
action.uri,
|
|
{ full: true, callbackAction: { name: 'play', shuffle: action.shuffle } },
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_ENQUEUE_PLAYLIST': {
|
|
const playlist = store.getState().core.items[action.uri];
|
|
if (playlist && playlist.tracks) {
|
|
store.dispatch(
|
|
mopidyActions.enqueueURIs({
|
|
uris: arrayOf('uri', playlist.tracks),
|
|
...action,
|
|
}),
|
|
);
|
|
break;
|
|
}
|
|
store.dispatch(
|
|
coreActions.loadPlaylist(
|
|
action.uri,
|
|
false,
|
|
{
|
|
name: 'enqueue',
|
|
...action,
|
|
},
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_ENQUEUE_ALBUM': {
|
|
const album = store.getState().core.items[action.uri];
|
|
if (album && album.tracks) {
|
|
store.dispatch(
|
|
mopidyActions.enqueueURIs({
|
|
uris: arrayOf('uri', album.tracks),
|
|
...action,
|
|
}),
|
|
);
|
|
break;
|
|
}
|
|
store.dispatch(
|
|
coreActions.loadAlbum(
|
|
action.uri,
|
|
false,
|
|
{
|
|
name: 'enqueue',
|
|
...action,
|
|
},
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_ENQUEUE_URIS': {
|
|
if (!action.uris || action.uris.length <= 0) {
|
|
this.props.uiActions.createNotification({
|
|
content: 'No URIs to enqueue',
|
|
level: 'warning',
|
|
});
|
|
break;
|
|
}
|
|
|
|
let remaining = action.uris.length;
|
|
let { at_position } = action;
|
|
|
|
// Uris are to go immediately after the currently-playing track (which could be paused)
|
|
if (action.play_next) {
|
|
const {
|
|
current_track,
|
|
queue,
|
|
} = store.getState().core;
|
|
if (current_track) {
|
|
// TODO: Refactor this to findIndex
|
|
for (let i = 0; i < queue.length; i += 1) {
|
|
if (queue[i].tlid === current_track.tlid) {
|
|
at_position = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
store.dispatch(uiActions.startProcess(
|
|
action.type,
|
|
{
|
|
content: i18n('services.mopidy.adding_uris', { count: remaining }),
|
|
remaining,
|
|
total: remaining,
|
|
},
|
|
));
|
|
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: i18n(
|
|
'services.mopidy.adding_uris_broadcast',
|
|
{ username: store.getState().pusher.username, count: remaining },
|
|
),
|
|
icon: (
|
|
store.getState().core.current_track
|
|
? getTrackIcon(store.getState().core.current_track, store.getState().core)
|
|
: false
|
|
),
|
|
},
|
|
},
|
|
));
|
|
|
|
// Prepare our URIS into batches. This allows performance gain of several URIs at once
|
|
// (which is a blocking request), but not so many that it locks the Mopidy server.
|
|
// It allows a window of opportunity for other requests to complete before we proceed to
|
|
// the next batch.
|
|
const batchSize = 5;
|
|
const batches = chunk(action.uris, batchSize);
|
|
|
|
// This is our process iterator
|
|
const run = () => {
|
|
const processor = store.getState().ui.processes[action.type];
|
|
if (processor && processor.status === 'cancelling') {
|
|
store.dispatch(uiActions.processCancelled(action.type));
|
|
return;
|
|
}
|
|
|
|
const uris = batches.shift();
|
|
|
|
request(store, 'tracklist.add', { uris, at_position })
|
|
.then(
|
|
(response) => {
|
|
const tlids = response.map((track) => track.tlid);
|
|
store.dispatch(pusherActions.addQueueMetadata(tlids, action.from));
|
|
|
|
// Re-run the batch checker in 100ms. This allows a small window for other server
|
|
// requests before our next batch. A little crude but it means the server isn't
|
|
// locked until we're completely done.
|
|
setTimeout(
|
|
() => {
|
|
if (!batches.length) {
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
} else {
|
|
remaining -= uris.length;
|
|
if (at_position !== null) at_position += uris.length;
|
|
store.dispatch(
|
|
uiActions.updateProcess(
|
|
action.type,
|
|
{
|
|
remaining,
|
|
content: i18n('services.mopidy.adding_uris', { count: remaining }),
|
|
},
|
|
),
|
|
);
|
|
run();
|
|
}
|
|
},
|
|
100,
|
|
);
|
|
},
|
|
(error) => {
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
store.dispatch(coreActions.handleException(
|
|
`Mopidy: ${error.message || 'Adding tracks failed'}`,
|
|
error,
|
|
));
|
|
},
|
|
);
|
|
};
|
|
|
|
run(); // Kick off the loop
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_PLAY_URIS':
|
|
const { from } = action;
|
|
let urisToPlay = Object.assign([], action.uris);
|
|
|
|
if (!urisToPlay || !urisToPlay.length) {
|
|
this.props.uiActions.createNotification({ content: 'No URIs to play', level: 'warning' });
|
|
break;
|
|
}
|
|
|
|
if (action.shuffle) {
|
|
urisToPlay = shuffle(urisToPlay);
|
|
}
|
|
|
|
// Stop the radio
|
|
if (store.getState().core.radio && store.getState().core.radio.enabled) {
|
|
store.dispatch(pusherActions.stopRadio());
|
|
}
|
|
|
|
// Clear tracklist (if set)
|
|
if (store.getState().ui.clear_tracklist_on_play) {
|
|
store.dispatch(mopidyActions.clearTracklist());
|
|
}
|
|
|
|
// Shuffle/random mode
|
|
if (store.getState().mopidy.random) {
|
|
var first_uri_index = Math.floor(Math.random() * urisToPlay.length);
|
|
} else {
|
|
var first_uri_index = 0;
|
|
}
|
|
var first_uri = urisToPlay[first_uri_index];
|
|
|
|
// add our first track
|
|
request(store, 'tracklist.add', { uris: [first_uri], at_position: 0 })
|
|
.then(
|
|
(response) => {
|
|
// play it (only if we got a successful lookup)
|
|
if (response.length > 0) {
|
|
store.dispatch(mopidyActions.changeTrack(response[0].tlid));
|
|
|
|
const tlids = [];
|
|
for (let i = 0; i < response.length; i++) {
|
|
tlids.push(response[i].tlid);
|
|
}
|
|
store.dispatch(pusherActions.addQueueMetadata(tlids, from));
|
|
} else {
|
|
store.dispatch(coreActions.handleException(
|
|
'Mopidy: Failed to add some tracks',
|
|
response,
|
|
));
|
|
}
|
|
|
|
// Remove our first_uri as we've already added it
|
|
urisToPlay.splice(first_uri_index, 1);
|
|
|
|
// And add the rest of our uris (if any)
|
|
if (urisToPlay.length > 0) {
|
|
// Wait a moment so the server can trigger track_changed etc
|
|
// this means our UI feels snappier as the first track shows up quickly
|
|
setTimeout(
|
|
() => {
|
|
store.dispatch(mopidyActions.enqueueURIs({
|
|
uris: urisToPlay,
|
|
at_position: 1,
|
|
from,
|
|
}));
|
|
},
|
|
100,
|
|
);
|
|
}
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
`Mopidy: ${error.message ? error.message : 'Adding tracks failed'}`,
|
|
error,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_REORDER_TRACKLIST':
|
|
request(store, 'tracklist.move', { start: action.range_start, end: action.range_start + action.range_length, to_position: action.insert_before })
|
|
.then(
|
|
() => {
|
|
// TODO: when complete, send event to confirm success/failure
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
`Mopidy: ${error.message ? error.message : 'Reorder failed'}`,
|
|
error,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_CLEAR_TRACKLIST':
|
|
request(store, 'tracklist.clear')
|
|
.then(
|
|
() => {
|
|
store.dispatch(coreActions.clearCurrentTrack());
|
|
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} cleared the playback queue`,
|
|
},
|
|
},
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_SHUFFLE_TRACKLIST':
|
|
request(store, 'tracklist.shuffle', { start: 1 })
|
|
.then(
|
|
() => {
|
|
store.dispatch(pusherActions.deliverBroadcast(
|
|
'notification',
|
|
{
|
|
notification: {
|
|
content: `${store.getState().pusher.username} shuffled the playback queue`,
|
|
},
|
|
},
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
/**
|
|
* =============================================================== SEARCHING ============
|
|
* ======================================================================================
|
|
* */
|
|
|
|
case 'MOPIDY_GET_SEARCH_RESULTS': {
|
|
const {
|
|
uri_schemes = [],
|
|
query = {},
|
|
} = action;
|
|
const types = query.type === 'all'
|
|
? ['artists', 'albums', 'tracks', 'playlists']
|
|
: [query.type];
|
|
|
|
const queue = [];
|
|
uri_schemes.forEach(
|
|
(uri_scheme) => types.forEach(
|
|
(type) => {
|
|
const item = {
|
|
type: query.type,
|
|
term: query.term,
|
|
requestType: type,
|
|
uri_scheme,
|
|
data: {
|
|
uris: [uri_scheme],
|
|
},
|
|
};
|
|
switch (type) {
|
|
case 'tracks':
|
|
item.data.query = { any: [query.term] };
|
|
break;
|
|
case 'artists':
|
|
item.data.query = { artist: [query.term] };
|
|
break;
|
|
case 'albums':
|
|
item.data.query = { album: [query.term] };
|
|
break;
|
|
case 'playlists':
|
|
// Searching for playlists is not supported, so we get a simple
|
|
// list of names and perform a client-side regex match
|
|
item.method = 'playlists.asList';
|
|
item.data = {};
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
queue.push(item);
|
|
},
|
|
),
|
|
);
|
|
|
|
if (queue.length) {
|
|
store.dispatch(uiActions.startProcess(
|
|
action.type,
|
|
{
|
|
content: i18n('services.mopidy.searching'),
|
|
total: queue.length,
|
|
remaining: queue.length,
|
|
},
|
|
));
|
|
|
|
processSearchQueue(store, queue);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_URIS': {
|
|
const { uris } = action;
|
|
request(store, 'library.lookup', { uris })
|
|
.then((response) => {
|
|
if (!response) return;
|
|
|
|
indexToArray(response).forEach((item) => {
|
|
store.dispatch(coreActions[`load${item.___model__}`](item.uri));
|
|
});
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_PLAYLIST': {
|
|
const processResponse = (response, fetchTracks = true) => {
|
|
const playlist = formatPlaylist({
|
|
can_edit: true,
|
|
provider: 'mopidy',
|
|
...response,
|
|
uri: action.uri, // Patch in the requested URI
|
|
type: 'playlist',
|
|
});
|
|
if (response.tracks && fetchTracks) {
|
|
store.dispatch(coreActions.itemLoaded({
|
|
...playlist,
|
|
tracks: undefined,
|
|
loading: 'tracks',
|
|
}));
|
|
|
|
request(store, 'library.lookup', { uris: arrayOf('uri', response.tracks) })
|
|
.then((tracksResponse) => {
|
|
const tracks = response.tracks.map((simpleTrack) => {
|
|
const fullTracks = tracksResponse[simpleTrack.uri];
|
|
return {
|
|
...simpleTrack,
|
|
...(fullTracks.length ? fullTracks[0] : {}),
|
|
};
|
|
});
|
|
playlist.tracks = injectSortId(formatTracks(tracks));
|
|
store.dispatch(coreActions.itemLoaded(playlist));
|
|
})
|
|
.catch(() => {
|
|
store.dispatch(coreActions.itemLoaded({
|
|
uri: playlist.uri,
|
|
loading: undefined,
|
|
}));
|
|
});
|
|
} else {
|
|
store.dispatch(coreActions.itemLoaded(playlist));
|
|
}
|
|
|
|
if (!playlist.images) {
|
|
store.dispatch(mopidyActions.getImages([playlist.uri]));
|
|
}
|
|
};
|
|
|
|
request(store, 'playlists.lookup', { uri: action.uri })
|
|
.then((playlistResponse) => {
|
|
if (playlistResponse) {
|
|
// Got a playlist from our playlists core, this is typically because it's a local
|
|
// playlist, or one that our backend user owns.
|
|
processResponse(playlistResponse);
|
|
} else {
|
|
console.info('Playlist not in playlists, fetching using library', action.uri);
|
|
// No match, so let's try fetching from foreign provider. This needs to happen when we
|
|
// don't have a HTTP API (eg Spotify) but the playlist is not ours (eg Tidal browse)
|
|
request(store, 'library.lookup', { uris: [action.uri] })
|
|
.then(({ [action.uri]: libraryResponse } = {}) => {
|
|
if (!libraryResponse) return;
|
|
processResponse({
|
|
tracks: libraryResponse,
|
|
can_edit: false,
|
|
...action.options.name ? { name: action.options.name } : {},
|
|
}, false);
|
|
});
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_ADD_PLAYLIST_TRACKS':
|
|
request(store, 'playlists.lookup', { uri: action.key })
|
|
.then((response) => {
|
|
if (!response) {
|
|
store.dispatch(coreActions.handleException(
|
|
i18n('errors.uri_not_found', { uri: action.key }),
|
|
));
|
|
return;
|
|
}
|
|
const tracks = action.tracks_uris.map((uri) => ({ __model__: 'Track', uri }));
|
|
const playlist = { ...response };
|
|
if (playlist.tracks) {
|
|
playlist.tracks = [...playlist.tracks, ...tracks];
|
|
} else {
|
|
playlist.tracks = tracks;
|
|
}
|
|
|
|
request(store, 'playlists.save', { playlist })
|
|
.then(() => {
|
|
store.dispatch({
|
|
type: 'PLAYLIST_TRACKS_ADDED',
|
|
key: action.key,
|
|
tracks_uris: action.tracks_uris,
|
|
});
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_REMOVE_PLAYLIST_TRACKS':
|
|
// reverse order our indexes (otherwise removing from top will affect the keys following)
|
|
function descending(a, b) {
|
|
return b - a;
|
|
}
|
|
var indexes = Object.assign([], action.tracks_indexes);
|
|
indexes.sort(descending);
|
|
|
|
request(store, 'playlists.lookup', { uri: action.key })
|
|
.then((response) => {
|
|
const playlist = { ...response };
|
|
for (let i = 0; i < indexes.length; i++) {
|
|
playlist.tracks.splice(indexes[i], 1);
|
|
}
|
|
request(store, 'playlists.save', { playlist })
|
|
.then((response) => {
|
|
store.dispatch({
|
|
type: 'PLAYLIST_TRACKS_REMOVED',
|
|
key: action.key,
|
|
tracks_indexes: action.tracks_indexes,
|
|
});
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_SAVE_PLAYLIST':
|
|
// Even though we have the full playlist in our index, our "playlists.save" request
|
|
// requires a Mopidy playlist object (with updates)
|
|
request(store, 'playlists.lookup', { uri: action.key })
|
|
.then((response) => {
|
|
const mopidy_playlist = { ...response, ...action.data };
|
|
|
|
request(store, 'playlists.save', { playlist: mopidy_playlist })
|
|
.then((response) => {
|
|
if (!response) return;
|
|
|
|
// Overwrite our playlist with the response to our save
|
|
// This is essential to get the updated URI from Mopidy
|
|
const playlist = {
|
|
...action.data,
|
|
...store.getState().core.items[action.key],
|
|
...response,
|
|
};
|
|
|
|
// When we rename a playlist, the URI also changes to reflect the name change.
|
|
// We need to update our index, as well as redirect our current page URL.
|
|
if (action.key !== playlist.uri) {
|
|
// Remove old playlist (by old key/uri) from index
|
|
// By providing the new key, the old playlist gets replaced with a redirector object
|
|
store.dispatch(coreActions.removeItem(action.key, playlist.uri));
|
|
store.dispatch(coreActions.removePinned(action.key));
|
|
store.dispatch(coreActions.addPinned(playlist));
|
|
}
|
|
|
|
store.dispatch(coreActions.itemLoaded(playlist));
|
|
store.dispatch(uiActions.createNotification({ content: 'Playlist saved' }));
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_REORDER_PLAYLIST_TRACKS':
|
|
request(store, 'playlists.lookup', { uri: action.key })
|
|
.then((response) => {
|
|
let playlist = { ...response };
|
|
const tracks = Object.assign([], playlist.tracks);
|
|
const tracks_to_move = [];
|
|
|
|
// calculate destination index: if dragging down, accommodate the offset created by the tracks we're moving
|
|
const { range_start } = action;
|
|
const { range_length } = action;
|
|
let { insert_before } = action;
|
|
if (insert_before > range_start) insert_before -= range_length;
|
|
|
|
// collate our tracks to be moved
|
|
for (let i = 0; i < range_length; i += 1) {
|
|
// add to FRONT: we work backwards to avoid screwing up our indexes
|
|
tracks_to_move.unshift(tracks[range_start + i]);
|
|
}
|
|
|
|
// remove tracks from their old location
|
|
tracks.splice(range_start, range_length);
|
|
|
|
// now plug them back in, in their new location
|
|
for (let i = 0; i < tracks_to_move.length; i += 1) {
|
|
tracks.splice(insert_before, 0, tracks_to_move[i]);
|
|
}
|
|
|
|
playlist = { ...playlist, tracks };
|
|
request(store, 'playlists.save', { playlist })
|
|
.then(() => {
|
|
store.dispatch({
|
|
type: 'PLAYLIST_TRACKS_REORDERED',
|
|
key: action.key,
|
|
range_start,
|
|
range_length,
|
|
insert_before: action.insert_before, // We've adjusted this, so use original
|
|
});
|
|
});
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_CREATE_PLAYLIST':
|
|
const data = {
|
|
name: action.playlist.name,
|
|
uri_scheme: action.playlist.scheme,
|
|
};
|
|
request(store, 'playlists.create', data)
|
|
.then((response) => {
|
|
const playlist = formatPlaylist({
|
|
...response,
|
|
...action,
|
|
can_edit: true,
|
|
type: 'playlist',
|
|
provider: 'mopidy',
|
|
});
|
|
store.dispatch(uiActions.createNotification({
|
|
content: i18n('actions.created', { name: i18n('playlist.title') }),
|
|
}));
|
|
if (action.playlist.tracks_uris) {
|
|
store.dispatch(coreActions.addTracksToPlaylist(
|
|
playlist.uri,
|
|
action.playlist.tracks_uris,
|
|
));
|
|
}
|
|
store.dispatch(coreActions.addToLibrary(
|
|
getProvider('playlists', 'm3u:')?.uri,
|
|
playlist,
|
|
));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_DELETE_PLAYLIST':
|
|
request(store, 'playlists.delete', { uri: action.uri })
|
|
.then(() => {
|
|
store.dispatch(uiActions.createNotification({
|
|
content: i18n('actions.deleted', { name: i18n('playlist.title') }),
|
|
}));
|
|
store.dispatch(coreActions.removeFromLibrary('m3u:playlists', action.uri));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_GET_ALBUM':
|
|
request(store, 'library.lookup', { uris: [action.uri] })
|
|
.then((_response) => {
|
|
const { uri } = action;
|
|
if (!_response) return;
|
|
let response = _response[uri];
|
|
if (!response || !response.length) return;
|
|
|
|
response = sortItems(response, 'track_number');
|
|
|
|
const album = {
|
|
...response[0].album,
|
|
source: 'local',
|
|
artists: formatArtists(response[0].artists),
|
|
tracks: formatTracks(response),
|
|
};
|
|
|
|
store.dispatch(coreActions.itemLoaded(album));
|
|
|
|
if (!album.images) {
|
|
store.dispatch(mopidyActions.getImages([album.uri]));
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_GET_ARTIST': {
|
|
request(store, 'library.lookup', { uris: [action.uri] })
|
|
.then((_response) => {
|
|
if (!_response) return;
|
|
const response = _response[action.uri];
|
|
if (!response || !response.length) return;
|
|
|
|
const albums = [];
|
|
for (let i = 0; i < response.length; i++) {
|
|
if (response[i].album) {
|
|
const album = {
|
|
...formatAlbum(response[i].album),
|
|
uri: response[i].album.uri,
|
|
};
|
|
if (album) {
|
|
const existingAlbum = albums.find(
|
|
(albumToCheck) => album.uri === albumToCheck.uri,
|
|
);
|
|
if (!existingAlbum) {
|
|
albums.push(album);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (albums) {
|
|
store.dispatch(coreActions.itemsLoaded(albums));
|
|
}
|
|
|
|
// Get the artist object from the track. This is a bit ugly because it's a simplified
|
|
// (Mopidy) artist object but gives us enough to fetch their name and artwork.
|
|
let raw_artist = null;
|
|
response.forEach((track) => {
|
|
const matchingArtist = track.artists.find((artist) => artist.uri === action.uri);
|
|
if (matchingArtist) {
|
|
raw_artist = matchingArtist;
|
|
}
|
|
});
|
|
|
|
const artist = {
|
|
uri: action.uri,
|
|
provider: 'mopidy',
|
|
albums_uris: arrayOf('uri', albums),
|
|
tracks: formatTracks(response),
|
|
...(raw_artist ? formatArtist(raw_artist) : {}),
|
|
};
|
|
|
|
store.dispatch(coreActions.itemLoaded(artist));
|
|
|
|
// We may not have a raw_artist if the tracks' artist is not found (ie the requested
|
|
// artist may be the albumartist).
|
|
if (raw_artist) {
|
|
store.dispatch(lastfmActions.getArtist(artist.uri, artist.name, artist.musicbrainz_id));
|
|
|
|
// Load supprting information from LastFM and Discogs
|
|
if (store.getState().spotify.enabled) {
|
|
store.dispatch(spotifyActions.getArtistImages(artist));
|
|
} else {
|
|
store.dispatch(discogsActions.getArtistImages(artist.uri, artist));
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_QUEUE':
|
|
request(store, 'tracklist.getTlTracks')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'QUEUE_LOADED',
|
|
tracks: response,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_QUEUE_HISTORY':
|
|
request(store, 'history.getHistory')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({
|
|
type: 'MOPIDY_QUEUE_HISTORY',
|
|
tracks: response,
|
|
});
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_CURRENT_TRACK':
|
|
request(store, 'playback.getCurrentTlTrack')
|
|
.then(
|
|
(response) => {
|
|
if (response && response.track) {
|
|
store.dispatch(mopidyActions.currentTrackLoaded(response));
|
|
}
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_CURRENT_TRACK_LOADED': {
|
|
store.dispatch(uiActions.setCurrentTrackTransition(false));
|
|
const track = formatTrack(action.tl_track);
|
|
if (track.uri) {
|
|
store.dispatch({
|
|
type: 'CURRENT_TRACK_LOADED',
|
|
track,
|
|
uri: track.uri,
|
|
});
|
|
store.dispatch(coreActions.loadTrack(track.uri, { full: true }));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_NEXT_TRACK':
|
|
request(store, 'tracklist.getNextTlid')
|
|
.then(
|
|
(response) => {
|
|
if (response && response >= 0) {
|
|
// Get the full track object from our tracklist
|
|
// We know it will be here, as the tlid refers to an item in this list
|
|
const track = applyFilter('tlid', response, store.getState().core.queue, true);
|
|
|
|
if (track && track.uri) {
|
|
store.dispatch({
|
|
type: 'NEXT_TRACK_LOADED',
|
|
uri: track.uri,
|
|
});
|
|
|
|
store.dispatch(coreActions.loadTrack(track.uri));
|
|
}
|
|
}
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
`Mopidy: ${error.message ? error.message : 'Could not get next track'}`,
|
|
error,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_TRACKS': {
|
|
const { uris, options: { full, lyrics } } = action;
|
|
request(store, 'library.lookup', { uris })
|
|
.then(
|
|
(_response) => {
|
|
if (!_response) return;
|
|
const tracks = compact(indexToArray(_response).map(
|
|
(results) => (results.length ? formatTrack(results[0]) : null),
|
|
));
|
|
|
|
store.dispatch(coreActions.itemsLoaded(tracks));
|
|
store.dispatch(mopidyActions.getImages(arrayOf('uri', tracks)));
|
|
|
|
tracks.forEach((track) => {
|
|
if (full) {
|
|
if (store.getState().lastfm.authorization) {
|
|
store.dispatch(lastfmActions.getTrack(track.uri));
|
|
}
|
|
}
|
|
if (lyrics && store.getState().genius.authorization) {
|
|
store.dispatch(geniusActions.findTrackLyrics(track.uri));
|
|
}
|
|
});
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
`Mopidy: ${error.message ? error.message : 'Could not get track'}`,
|
|
error,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_STREAM_TITLE':
|
|
request(store, 'playback.getStreamTitle')
|
|
.then(
|
|
(stream_title) => {
|
|
if (!stream_title) return;
|
|
|
|
store.dispatch(coreActions.streamTitleLoaded(stream_title));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'VIEW__GET_RANDOM_TRACKS':
|
|
request(store, 'library.browse', { uri: 'local:directory?type=track' })
|
|
.then(
|
|
(_response) => {
|
|
if (!_response || !_response.length) return;
|
|
|
|
const uris = sampleSize(arrayOf('uri', _response), action.limit);
|
|
|
|
request(store, 'library.lookup', { uris })
|
|
.then(
|
|
(_response) => {
|
|
if (!_response) return;
|
|
|
|
const random_tracks = indexToArray(_response).map((results) => results[0]);
|
|
const view = store.getState().core.view || {};
|
|
store.dispatch(coreActions.viewDataLoaded({
|
|
random_tracks: [
|
|
...(view.random_tracks ? view.random_tracks : []),
|
|
...random_tracks,
|
|
],
|
|
uris: [
|
|
...(view.uris ? view.uris : []),
|
|
...uris,
|
|
],
|
|
}));
|
|
},
|
|
);
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'MOPIDY_GET_IMAGES': {
|
|
const { uris } = action;
|
|
if (!uris) break;
|
|
|
|
request(store, 'library.getImages', { uris })
|
|
.then((response) => {
|
|
const itemsWithImages = [];
|
|
Object.keys(response).forEach((uri) => {
|
|
const images = response[uri];
|
|
|
|
if (images) {
|
|
itemsWithImages.push({
|
|
uri,
|
|
images: formatImages(digestMopidyImages(store.getState().mopidy, images)),
|
|
});
|
|
} else {
|
|
store.dispatch(lastfmActions.getImages(uri));
|
|
}
|
|
});
|
|
|
|
if (itemsWithImages.length) {
|
|
store.dispatch(coreActions.itemsLoaded(itemsWithImages));
|
|
}
|
|
});
|
|
|
|
next(action);
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_DIRECTORY':
|
|
const uri = action.uri ? decodeURIComponent(action.uri) : null;
|
|
store.dispatch({
|
|
type: 'MOPIDY_DIRECTORY_LOADING',
|
|
uri,
|
|
});
|
|
|
|
const processResults = (results) => {
|
|
const tracks = [];
|
|
const subdirectories = [];
|
|
const trackUrisToLoad = [];
|
|
const subdirectoryImagesToLoad = [];
|
|
const playlistsToLoad = [];
|
|
|
|
results.forEach((item) => {
|
|
if (item.__model__ === 'Track') {
|
|
tracks.push(formatTrack(item));
|
|
} else if (item.__model__ === 'Ref' && item.type === 'track') {
|
|
tracks.push(formatTrack({ ...item, loading: true }));
|
|
trackUrisToLoad.push(item.uri);
|
|
} else if (item.__model__ === 'Ref' && item.type === 'album') {
|
|
subdirectories.push(formatAlbum({ ...item, loading: true }));
|
|
subdirectoryImagesToLoad.push(item.uri);
|
|
} else if (item.__model__ === 'Ref' && item.type === 'artist') {
|
|
subdirectories.push(formatArtist({ ...item, loading: true }));
|
|
subdirectoryImagesToLoad.push(item.uri);
|
|
} else if (item.__model__ === 'Ref' && item.type === 'playlist') {
|
|
// Tidal moods and genres incorrectly marked as Playlist type
|
|
if (
|
|
item.uri.indexOf('tidal:mood') > -1 ||
|
|
item.uri.indexOf('tidal:genre') > -1
|
|
) {
|
|
subdirectories.push({
|
|
...item,
|
|
type: 'directory',
|
|
});
|
|
} else {
|
|
subdirectories.push(formatPlaylist({ ...item, loading: true }));
|
|
subdirectoryImagesToLoad.push(item.uri);
|
|
playlistsToLoad.push(item.uri);
|
|
}
|
|
} else {
|
|
subdirectoryImagesToLoad.push(item.uri);
|
|
subdirectories.push(item);
|
|
}
|
|
});
|
|
|
|
store.dispatch({
|
|
type: 'MOPIDY_DIRECTORY_LOADED',
|
|
directory: {
|
|
uri,
|
|
tracks,
|
|
subdirectories,
|
|
},
|
|
});
|
|
|
|
if (trackUrisToLoad.length) {
|
|
console.info(`Loading ${trackUrisToLoad.length} track URIs`);
|
|
request(store, 'library.lookup', { uris: trackUrisToLoad })
|
|
.then((response) => {
|
|
const fullTrackObjects = Object.values(response).map(
|
|
(t) => (t.length > 0 ? formatTrack(t[0]) : undefined),
|
|
);
|
|
|
|
store.dispatch({
|
|
type: 'MOPIDY_DIRECTORY_LOADED',
|
|
directory: {
|
|
uri,
|
|
tracks: fullTrackObjects.filter((t) => t instanceof Object),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
const imagesLoaded = new Promise((resolve) => {
|
|
if (subdirectoryImagesToLoad.length) {
|
|
console.info(`Loading ${subdirectoryImagesToLoad.length} subdirectory URIs`);
|
|
request(store, 'library.getImages', { uris: subdirectoryImagesToLoad })
|
|
.then((response) => {
|
|
resolve(response);
|
|
});
|
|
} else {
|
|
resolve({});
|
|
}
|
|
});
|
|
|
|
const playlistsLoaded = new Promise((resolve) => {
|
|
const playlists = {};
|
|
if (playlistsToLoad.length) {
|
|
console.info(`Loading ${playlistsToLoad.length} playlist URIs`);
|
|
const toLoad = playlistsToLoad.length;
|
|
let loaded = 0;
|
|
playlistsToLoad.map((uri) => {
|
|
request(store, 'playlists.lookup', { uri })
|
|
.then((playlist) => {
|
|
playlists[uri] = formatPlaylist({
|
|
name: playlist.name,
|
|
uri: playlist.uri,
|
|
tracks: formatTracks(playlist.tracks),
|
|
});
|
|
loaded += 1;
|
|
if (loaded === toLoad) {
|
|
resolve(playlists);
|
|
}
|
|
})
|
|
});
|
|
} else {
|
|
resolve(playlists);
|
|
}
|
|
});
|
|
|
|
imagesLoaded.then((response) => {
|
|
|
|
const subdirectoriesWithImages = subdirectories.map((subdir) => {
|
|
let images = response[subdir.uri] || undefined;
|
|
if (images) {
|
|
images = formatImages(digestMopidyImages(store.getState().mopidy, images));
|
|
}
|
|
return {
|
|
...subdir,
|
|
images,
|
|
};
|
|
});
|
|
|
|
playlistsLoaded.then((playlists) => {
|
|
const subdirectoriesWithPlaylists = subdirectoriesWithImages.map((subdir) => {
|
|
const playlist = playlists[subdir.uri] || {};
|
|
return {
|
|
...subdir,
|
|
...playlist,
|
|
};
|
|
});
|
|
|
|
store.dispatch({
|
|
type: 'MOPIDY_DIRECTORY_LOADED',
|
|
directory: {
|
|
uri,
|
|
subdirectories: subdirectoriesWithPlaylists,
|
|
},
|
|
});
|
|
|
|
});
|
|
});
|
|
};
|
|
|
|
const getBrowse = () => request(store, 'library.browse', { uri })
|
|
.then((response) => processResults(response));
|
|
|
|
if (uri) {
|
|
request(store, 'library.lookup', { uris: [uri] })
|
|
.then((response) => {
|
|
const {
|
|
[uri]: results = [],
|
|
} = response;
|
|
|
|
// Not all endpoints give us tracks/subdirectories to library.lookup
|
|
if (!results.length || uri.startsWith('file:') || uri.startsWith('ytmusic:')) {
|
|
console.info(`No 'library.lookup' results for ${uri}, trying 'library.browse'`);
|
|
getBrowse();
|
|
} else {
|
|
processResults(results);
|
|
}
|
|
});
|
|
} else {
|
|
getBrowse();
|
|
}
|
|
break;
|
|
|
|
case 'MOPIDY_DIRECTORY':
|
|
if (store.getState().ui.allow_reporting && action.data) {
|
|
ReactGA.event({ category: 'Directory', action: 'Load', label: action.data.uri });
|
|
}
|
|
next(action);
|
|
break;
|
|
|
|
/**
|
|
* My Music libraries
|
|
*/
|
|
case 'MOPIDY_GET_LIBRARY_ARTISTS':
|
|
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
|
|
|
request(store, 'library.browse', { uri: action.uri })
|
|
.then((raw_response) => {
|
|
// No items in our library
|
|
if (!raw_response.length) {
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: 'mopidy:library:artists',
|
|
type: 'artists',
|
|
items_uris: [],
|
|
}));
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
}
|
|
|
|
// Convert local URI to actual artist URI
|
|
// See https://github.com/mopidy/mopidy-local-sqlite/issues/39
|
|
const response = raw_response.map((artist) => ({
|
|
...formatArtist(artist),
|
|
uri: artist.uri.replace('local:directory?albumartist=', '').replace('local:directory?artist=', ''),
|
|
}));
|
|
|
|
store.dispatch(coreActions.itemsLoaded(response));
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: action.uri,
|
|
type: 'artists',
|
|
items_uris: arrayOf('uri', response),
|
|
}));
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_GET_LIBRARY_MOODS': {
|
|
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
|
|
|
request(store, 'library.browse', { uri: action.uri })
|
|
.then((response) => {
|
|
const moods = response.map((mood) => ({
|
|
...formatSimpleObject(mood),
|
|
type: 'mood',
|
|
}));
|
|
|
|
store.dispatch(
|
|
uiActions.updateProcess(
|
|
action.type,
|
|
{
|
|
total: moods.length,
|
|
remaining: moods.length,
|
|
},
|
|
),
|
|
);
|
|
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: action.uri,
|
|
type: 'moods',
|
|
items_uris: arrayOf('uri', moods),
|
|
}));
|
|
store.dispatch(coreActions.itemsLoaded(moods));
|
|
store.dispatch(uiActions.stopLoading(action.uri));
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_LIBRARY_FEATURED_PLAYLISTS': {
|
|
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
|
|
|
request(store, 'library.browse', { uri: action.uri })
|
|
.then((response) => {
|
|
const moods = response.map((mood) => ({
|
|
...formatSimpleObject(mood),
|
|
type: 'playlist_group',
|
|
}));
|
|
|
|
store.dispatch(
|
|
uiActions.updateProcess(
|
|
action.type,
|
|
{
|
|
total: moods.length,
|
|
remaining: moods.length,
|
|
},
|
|
),
|
|
);
|
|
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: action.uri,
|
|
type: 'featured_playlists',
|
|
items_uris: arrayOf('uri', moods),
|
|
}));
|
|
store.dispatch(coreActions.itemsLoaded(moods));
|
|
store.dispatch(uiActions.stopLoading(action.uri));
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_PLAYLIST_GROUP': {
|
|
const processKey = `playlist_group_${action.uri}`;
|
|
const decodedUri = action.uri ? decodeURIComponent(action.uri) : null;
|
|
const playlistGroup = formatPlaylistGroup({
|
|
uri: decodedUri,
|
|
loading: true,
|
|
});
|
|
store.dispatch(uiActions.startProcess(
|
|
processKey,
|
|
{ content: i18n('services.mopidy.loading_artwork') },
|
|
));
|
|
store.dispatch(coreActions.itemLoaded(playlistGroup));
|
|
request(store, 'library.browse', { uri: action.uri })
|
|
.then((browse) => {
|
|
const playlists = browse.map((item) => formatPlaylist({
|
|
...item,
|
|
images: [], // Images is a playlist dependency, so this prevents triggering full load
|
|
}));
|
|
const playlists_uris = uniq(arrayOf('uri', playlists));
|
|
const allUris = [...playlists_uris];
|
|
store.dispatch(coreActions.itemLoaded({
|
|
...playlistGroup,
|
|
loading: false,
|
|
playlists_uris,
|
|
}));
|
|
store.dispatch(coreActions.itemsLoaded(playlists));
|
|
store.dispatch(
|
|
uiActions.updateProcess(
|
|
processKey,
|
|
{
|
|
total: allUris.length,
|
|
remaining: allUris.length,
|
|
},
|
|
),
|
|
);
|
|
|
|
const run = () => {
|
|
if (allUris.length) {
|
|
const uris = allUris.splice(0, 5);
|
|
const processor = store.getState().ui.processes[processKey];
|
|
if (processor && processor.status === 'cancelling') {
|
|
store.dispatch(uiActions.processCancelled(processKey));
|
|
return;
|
|
}
|
|
store.dispatch(uiActions.updateProcess(processKey, { remaining: allUris.length }));
|
|
|
|
request(store, 'library.getImages', { uris })
|
|
.then(
|
|
(response) => {
|
|
const withImages = uris.map((uri) => {
|
|
let images = response[uri] || [];
|
|
if (images) {
|
|
images = formatImages(digestMopidyImages(store.getState().mopidy, images));
|
|
}
|
|
return {
|
|
uri,
|
|
images,
|
|
};
|
|
});
|
|
store.dispatch(coreActions.itemsLoaded(withImages));
|
|
run();
|
|
},
|
|
);
|
|
} else {
|
|
store.dispatch(uiActions.processFinished(processKey));
|
|
}
|
|
};
|
|
|
|
run();
|
|
});
|
|
break;
|
|
}
|
|
case 'MOPIDY_GET_LIBRARY_PLAYLISTS': {
|
|
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
|
const scheme = action.uri.split(':')[0];
|
|
|
|
request(store, 'playlists.asList').then((browseResponse) => {
|
|
const allUris = arrayOf('uri', browseResponse).filter((uri) => uri.startsWith(scheme));
|
|
const unloadedUris = [...allUris];
|
|
|
|
store.dispatch(
|
|
uiActions.updateProcess(
|
|
action.type,
|
|
{
|
|
total: allUris.length,
|
|
remaining: unloadedUris.length,
|
|
},
|
|
),
|
|
);
|
|
|
|
const run = () => {
|
|
if (unloadedUris.length) {
|
|
const uri = unloadedUris.splice(0, 1)[0];
|
|
const processor = store.getState().ui.processes[action.type];
|
|
|
|
if (processor && processor.status === 'cancelling') {
|
|
store.dispatch(uiActions.processCancelled(action.type));
|
|
store.dispatch(uiActions.stopLoading(action.uri));
|
|
return;
|
|
}
|
|
store.dispatch(uiActions.updateProcess(action.type, { remaining: unloadedUris.length }));
|
|
|
|
request(store, 'playlists.lookup', { uri }).then((lookupResponse) => {
|
|
if (lookupResponse) {
|
|
const playlist = formatPlaylist({
|
|
name: lookupResponse.name,
|
|
uri: lookupResponse.uri,
|
|
can_edit: true, // TODO: Confirm whether some are uneditable??
|
|
last_modified: lookupResponse.last_modified,
|
|
// Don't include simple tracks; they don't contain duration, artist, etc.
|
|
tracks_total: lookupResponse?.tracks?.length || null,
|
|
});
|
|
|
|
store.dispatch(coreActions.itemLoaded(playlist));
|
|
}
|
|
run();
|
|
});
|
|
} else {
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: action.uri,
|
|
type: 'playlists',
|
|
items_uris: allUris,
|
|
}));
|
|
}
|
|
};
|
|
|
|
run();
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'MOPIDY_GET_LIBRARY_ALBUMS':
|
|
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
|
|
|
request(store, 'library.browse', { uri: action.uri })
|
|
.then((browseResponse) => {
|
|
const allUris = arrayOf('uri', browseResponse);
|
|
|
|
store.dispatch(uiActions.updateProcess(
|
|
action.type,
|
|
{
|
|
remaining: allUris.length,
|
|
total: allUris.length,
|
|
},
|
|
));
|
|
|
|
const run = () => {
|
|
if (allUris.length) {
|
|
const uris = allUris.splice(0, 10);
|
|
const processor = store.getState().ui.processes[action.type];
|
|
|
|
if (processor && processor.status === 'cancelling') {
|
|
store.dispatch(uiActions.processCancelled(action.type));
|
|
store.dispatch(uiActions.stopLoading(action.uri));
|
|
return;
|
|
}
|
|
store.dispatch(uiActions.updateProcess(action.type, { remaining: allUris.length }));
|
|
|
|
request(store, 'library.lookup', { uris })
|
|
.then(
|
|
(lookupResponse) => {
|
|
const libraryItems = [];
|
|
indexToArray(lookupResponse).forEach(
|
|
(tracks) => {
|
|
if (tracks.length <= 0) {
|
|
console.info('Ignoring item; no results in lookup');
|
|
return;
|
|
}
|
|
|
|
libraryItems.push({
|
|
artists: tracks[0].artists ? formatArtists(tracks[0].artists) : null,
|
|
tracks: formatTracks(tracks),
|
|
last_modified: tracks[0].last_modified,
|
|
...formatAlbum(tracks[0].album),
|
|
});
|
|
},
|
|
);
|
|
|
|
if (libraryItems.length) {
|
|
store.dispatch(coreActions.itemsLoaded(libraryItems));
|
|
}
|
|
run();
|
|
},
|
|
);
|
|
} else {
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: action.uri,
|
|
type: 'albums',
|
|
items_uris: arrayOf('uri', browseResponse),
|
|
}));
|
|
}
|
|
};
|
|
|
|
run();
|
|
});
|
|
break;
|
|
|
|
case 'MOPIDY_GET_LIBRARY_TRACKS':
|
|
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
|
|
|
request(store, 'library.browse', { uri: action.uri })
|
|
.then((browseResponse) => {
|
|
const allUris = arrayOf('uri', browseResponse);
|
|
|
|
store.dispatch(uiActions.updateProcess(
|
|
action.type,
|
|
{
|
|
remaining: allUris.length,
|
|
total: allUris.length,
|
|
},
|
|
));
|
|
|
|
const run = () => {
|
|
if (allUris.length) {
|
|
const uris = allUris.splice(0, 100);
|
|
const processor = store.getState().ui.processes[action.type];
|
|
|
|
if (processor && processor.status === 'cancelling') {
|
|
store.dispatch(uiActions.processCancelled(action.type));
|
|
store.dispatch(uiActions.stopLoading('mopidy:library:tracks'));
|
|
return;
|
|
}
|
|
store.dispatch(uiActions.updateProcess(action.type, { remaining: allUris.length }));
|
|
|
|
request(store, 'library.lookup', { uris })
|
|
.then(
|
|
(lookupResponse) => {
|
|
const libraryItems = compact(
|
|
indexToArray(lookupResponse).map(
|
|
(results) => (results.length ? formatTrack(results[0]) : null),
|
|
),
|
|
);
|
|
|
|
if (libraryItems.length) {
|
|
store.dispatch(coreActions.itemsLoaded(libraryItems));
|
|
}
|
|
run();
|
|
},
|
|
);
|
|
} else {
|
|
store.dispatch(uiActions.processFinished(action.type));
|
|
store.dispatch(coreActions.libraryLoaded({
|
|
uri: action.uri,
|
|
type: 'tracks',
|
|
items_uris: arrayOf('uri', browseResponse),
|
|
}));
|
|
}
|
|
};
|
|
|
|
run();
|
|
});
|
|
break;
|
|
|
|
default:
|
|
next(action);
|
|
break;
|
|
}
|
|
};
|
|
}());
|
|
|
|
export default MopidyMiddleware;
|