Files
Iris/src/js/services/core/middleware.js
2020-11-07 16:46:09 +13:00

771 lines
23 KiB
JavaScript
Executable File

import React from 'react';
import ReactGA from 'react-ga';
import localForage from 'localforage';
import { compact } from 'lodash';
import { arrayOf } from '../../util/arrays';
import URILink from '../../components/URILink';
import {
uriSource,
upgradeSpotifyPlaylistUris,
uriType,
titleCase,
} from '../../util/helpers';
import { ensureLoaded } from '../../util/library';
import {
formatTracks,
formatTrack,
formatSimpleObject,
injectSortId,
} from '../../util/format';
import { handleException } from './actions';
const coreActions = require('./actions.js');
const uiActions = require('../ui/actions.js');
const mopidyActions = require('../mopidy/actions.js');
const googleActions = require('../google/actions.js');
const spotifyActions = require('../spotify/actions.js');
const CoreMiddleware = (function () {
return (store) => (next) => (action = {}) => {
const {
core,
ui: {
allow_reporting,
log_actions,
},
mopidy,
spotify,
} = store.getState();
window.localForage = localForage;
// Attach store to window variable. This enables debug tools to directly call on
// store.getState()
window._store = store;
if (log_actions) {
const ignored_actions = [
'START_LOADING',
'STOP_LOADING',
];
if (!ignored_actions.includes(action.type)) {
console.log(action);
}
}
switch (action.type) {
case 'HANDLE_EXCEPTION':
const state = store.getState();
const exported_state = {
core: { ...state.core },
ui: { ...state.ui },
spotify: { ...state.spotify },
mopidy: { ...state.mopidy },
pusher: { ...state.pusher },
};
const { message } = action;
let { description } = action;
// Construct meaningful message and description
if (!description) {
if (action.data.xhr && action.data.xhr.responseText) {
const xhr_response = JSON.parse(action.data.xhr.responseText);
if (xhr_response.error && xhr_response.error.message) {
description = xhr_response.error.message;
}
} else if (action.data.xhr) {
description = `${action.data.xhr.status} ${action.data.xhr.statusText}`;
}
}
// Strip out non-essential store info
delete exported_state.core.albums;
delete exported_state.core.artists;
delete exported_state.core.playlists;
delete exported_state.core.users;
delete exported_state.core.queue_metadata;
delete exported_state.core.current_tracklist;
delete exported_state.spotify.library_albums;
delete exported_state.spotify.library_artists;
delete exported_state.spotify.library_playlists;
delete exported_state.spotify.autocomplete_results;
delete exported_state.mopidy.library_albums;
delete exported_state.mopidy.library_artists;
delete exported_state.mopidy.library_playlists;
const data = {
...action.data,
message,
description,
state: exported_state,
};
// Log with Analytics
if (allow_reporting) {
ReactGA.event({
category: 'Error',
action: message,
label: description,
nonInteraction: true,
});
}
if (action.show_notification) {
store.dispatch(uiActions.createNotification({ content: message, level: 'error', description }));
}
console.error(message, description, data);
break;
case 'UNSUPPORTED_ACTION':
store.dispatch(handleException(
`Action failed (${action.name})`,
{ description: 'Not supported on this type of object' },
));
break;
case 'PLAY_PLAYLIST':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Play', label: action.uri });
}
next(action);
break;
case 'SAVE_PLAYLIST':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Save', label: action.key });
}
next(action);
break;
case 'CREATE_PLAYLIST':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Create', label: +action.name });
}
next(action);
break;
case 'REORDER_PLAYLIST_TRACKS':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Reorder tracks', label: action.key });
}
next(action);
break;
case 'ADD_PLAYLIST_TRACKS':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Add tracks', label: action.playlist_uri });
}
next(action);
break;
case 'REMOVE_PLAYLIST_TRACKS':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Remove tracks', label: action.playlist_uri });
}
next(action);
break;
case 'DELETE_PLAYLIST':
if (allow_reporting) {
ReactGA.event({ category: 'Playlist', action: 'Delete', label: action.uri });
}
next(action);
break;
case 'START_SEARCH': {
const { query } = action;
const {
ui: {
allow_reporting,
uri_schemes_search_enabled = [],
search_settings,
},
} = store.getState();
if (allow_reporting) {
ReactGA.event({
category: 'Search',
action: 'Started',
label: `${query.type}: ${query.term}`,
});
}
console.info(`Searching for ${query.type} matching "${query.term}"`);
// Trigger reducer immediately; this will hose out any previous results
next(action);
if (uri_schemes_search_enabled.includes('spotify:')) {
if (!search_settings || search_settings.spotify) {
store.dispatch(spotifyActions.getSearchResults(query));
}
}
store.dispatch(mopidyActions.getSearchResults(
query,
100,
uri_schemes_search_enabled.filter((i) => i !== 'spotify:'), // Omit Spotify; handled above
));
break;
}
case 'SEARCH_RESULTS_LOADED': {
const {
query: {
term,
type,
},
resultType,
results,
} = action;
const {
core: {
search_results: {
query: {
term: prevTerm,
type: prevType,
} = {},
...allResults
} = {},
} = {},
} = store.getState();
// Add to our existing results, so long as the search term is the same
const search_results = {
query: { term, type },
...(term === prevTerm && type === prevType ? allResults : {}),
};
// Merge our new results with the existing (if any)
search_results[resultType] = [
...(search_results[resultType] || []),
...results,
];
next({
...action,
search_results,
});
break;
}
case 'PLAYLIST_TRACKS_ADDED': {
const {
key,
tracks_uris,
} = action;
const {
core: {
items: {
[key]: asset,
},
},
} = store.getState();
store.dispatch(uiActions.createNotification({
content: (
<span>
Added {tracks_uris.length} tracks to <URILink uri={key}>{asset ? asset.name : 'playlist'}</URILink>
</span>
),
}));
switch (uriSource(key)) {
case 'spotify':
store.dispatch(spotifyActions.getPlaylist(key));
break;
case 'm3u':
store.dispatch(mopidyActions.getPlaylist(key));
break;
default:
break;
}
next(action);
break;
}
// This applies our new sort order based on the origional request (rather than a response)
// This means we don't need to re-fetch the whole playlist after every sort.
case 'PLAYLIST_TRACKS_REORDERED': {
const {
key,
snapshot_id,
} = action;
let { insert_before } = action;
const playlist = { ...core.items[key] };
const tracks = Object.assign([], playlist.tracks);
// handle insert_before offset if we're moving BENEATH where we're slicing tracks
if (insert_before > action.range_start) {
insert_before -= action.range_length;
}
// cut our moved tracks into a new array
const tracks_to_move = tracks.splice(action.range_start, action.range_length);
tracks_to_move.reverse();
for (let i = 0; i < tracks_to_move.length; i++) {
tracks.splice(insert_before, 0, tracks_to_move[i]);
}
console.log({ action, tracks: injectSortId(tracks) });
store.dispatch(coreActions.itemLoaded({
...playlist,
tracks: injectSortId(tracks),
snapshot_id,
}));
break;
}
case 'PLAYLIST_TRACKS_REMOVED': {
const {
key,
snapshot_id,
} = action;
const playlist = { ...core.items[key] };
const tracks = Object.assign([], playlist.tracks);
const indexes = action.tracks_indexes.reverse();
for (let i = 0; i < indexes.length; i++) {
tracks.splice(indexes[i], 1);
}
store.dispatch(coreActions.itemLoaded({
...playlist,
tracks,
snapshot_id,
}));
break;
}
/**
* Asset Load commands
*
* These are called from views and other middleware to load
* assets. This is where we can return already indexed records
* where appropriate
* */
case 'LOAD_ITEMS':
action.uris.forEach((uri) => {
store.dispatch(uiActions.startLoading(uri));
store.dispatch({
type: `LOAD_${uriType(uri).toUpperCase()}`,
uri,
options: action.options,
});
});
break;
case 'LOAD_TRACK': {
const fetch = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(spotifyActions.getTrack(action.uri, action.options));
if (spotify.me) {
store.dispatch(spotifyActions.following(action.uri));
}
break;
default:
store.dispatch(mopidyActions.getTrack(action.uri, action.options));
break;
}
};
ensureLoaded({
store,
action,
fetch,
dependents: ['images'],
fullDependents: ['lyrics_results'],
});
next(action);
break;
}
case 'LOAD_ALBUM': {
const fetch = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(spotifyActions.getAlbum(action.uri, action.options));
if (spotify.me) {
store.dispatch(spotifyActions.following(action.uri));
}
break;
default:
store.dispatch(mopidyActions.getAlbum(action.uri, action.options));
break;
};
};
ensureLoaded({
store,
action,
fetch,
dependents: ['images'],
fullDependents: ['tracks'],
});
next(action);
break;
}
case 'LOAD_ARTIST': {
const fetch = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(spotifyActions.getArtist(action.uri, action.options));
if (spotify.me) {
store.dispatch(spotifyActions.following(action.uri));
}
break;
default:
store.dispatch(mopidyActions.getArtist(action.uri, action.options));
break;
}
};
ensureLoaded({
store,
action,
fetch,
dependents: ['images'],
fullDependents: ['tracks', 'albums_uris'],
});
next(action);
break;
}
case 'LOAD_PLAYLIST': {
const fetch = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(spotifyActions.getPlaylist(action.uri, action.options));
if (spotify.me) {
store.dispatch(spotifyActions.following(action.uri));
}
break;
default:
store.dispatch(mopidyActions.getPlaylist(action.uri, action.options));
break;
}
};
ensureLoaded({
store,
action,
fetch,
dependents: ['images'],
fullDependents: ['tracks'],
});
next(action);
break;
}
case 'LOAD_USER': {
const fetch = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(spotifyActions.getUser(action.uri, action.options));
if (spotify.me) {
store.dispatch(spotifyActions.following(action.uri));
}
break;
default:
// No mopidy user model
break;
}
};
ensureLoaded({
store,
action,
fetch,
fullDependents: ['playlists_uris'],
});
next(action);
break;
}
case 'LOAD_CATEGORY':
const fetch = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(spotifyActions.getCategory(action.uri, action.options));
break;
default:
// No mopidy category model
break;
}
};
ensureLoaded({
store,
action,
fetch,
fullDependents: ['playlists_uris'],
});
next(action);
break;
case 'LOAD_LIBRARY':
store.dispatch(uiActions.startLoading(action.uri, action.uri));
const fetchLibrary = () => {
switch (uriSource(action.uri)) {
case 'spotify':
store.dispatch(
spotifyActions[`getLibrary${titleCase(uriType(action.uri))}`](action.options.forceRefetch),
);
break;
case 'google':
store.dispatch(
googleActions[`getLibrary${titleCase(uriType(action.uri))}`](),
);
break;
default:
store.dispatch(
mopidyActions[`getLibrary${titleCase(uriType(action.uri))}`](),
);
break;
}
};
if (action.options.forceRefetch) {
console.info(`Force-refetching "${action.uri}"`);
fetchLibrary();
break;
}
if (store.getState().core.libraries[action.uri]) {
console.info(`${action.uri}" already in index`);
store.dispatch(uiActions.stopLoading(action.uri));
break;
}
localForage.getItem(action.uri).then((library) => {
if (library) {
console.info(`Restoring "${action.uri}" and ${library.items_uris.length} items from database`);
const promises = library.items_uris.map((libraryItem) => localForage.getItem(libraryItem));
Promise.all(promises).then(
(libraryItems) => {
store.dispatch(coreActions.restoreItemsFromColdStore(compact(libraryItems)));
store.dispatch(coreActions.restoreLibraryFromColdStore(library));
},
);
} else {
fetchLibrary();
}
});
next(action);
break;
case 'UNLOAD_LIBRARY': {
localForage.removeItem(action.uri);
next(action);
break;
}
case 'ADD_TO_LIBRARY': {
const library = store.getState().core.libraries[action.uri];
if (library) {
library.items_uris.push(action.item.uri);
store.dispatch(coreActions.libraryLoaded(library));
} else {
// Clear our stored library. This prevents the next call from possibly restoring a stale
// library listing.
localForage.removeItem(action.uri);
}
store.dispatch(coreActions.itemLoaded({ ...action.item, in_library: true }));
next(action);
break;
}
case 'REMOVE_FROM_LIBRARY': {
const library = store.getState().core.libraries[action.uri];
if (library) {
const items_uris = library.items_uris.filter((uri) => uri !== action.itemUri);
store.dispatch(coreActions.libraryLoaded({
...library,
items_uris,
}));
}
store.dispatch(coreActions.removeItem(action.itemUri));
localForage.removeItem(action.itemUri);
next(action);
break;
}
/**
* Index actions
* These modify our asset indexes, which are used globally
* */
case 'CURRENT_TRACK_LOADED': {
const track = formatTrack(action.track);
store.dispatch(coreActions.itemLoaded(track));
next({
...action,
track,
});
break;
}
case 'QUEUE_LOADED':
store.dispatch(coreActions.tracksLoaded(action.tracks));
action.tracks = formatTracks(action.tracks);
next(action);
break;
case 'ITEMS_LOADED':
const mergedItems = [];
action.items.forEach((item) => {
mergedItems.push({
...core.items[item.uri] || {},
...item,
});
});
store.dispatch(uiActions.stopLoading(arrayOf('uri', action.items)));
store.dispatch(coreActions.updateColdStore(mergedItems));
next({
...action,
items: mergedItems,
});
break;
case 'LIBRARY_LOADED':
store.dispatch(uiActions.stopLoading(action.library.uri));
store.dispatch(coreActions.updateColdStore([action.library]));
next(action);
break;
/**
* Loaded more linked assets
* Often fired during lazy-loading or async asset grabbing.
* We link the parent to these indexed records by {type}s_uris
* */
case 'LOADED_MORE':
var parent_type_plural = `${action.parent_type}s`;
var parent_index = { ...core[`${action.parent_type}s`] };
var parent = { ...parent_index[action.parent_key] };
if (action.records_data.items !== undefined) {
var records = action.records_data.items;
} else if (action.records_data.tracks !== undefined) {
var records = action.records_data.tracks;
} else if (action.records_data.artists !== undefined) {
var records = action.records_data.artists;
} else if (action.records_data.albums !== undefined) {
var records = action.records_data.albums;
} else if (action.records_data.playlists !== undefined) {
var records = action.records_data.playlists;
} else {
var records = action.records_data;
}
// Pre-emptively format tracks
// Providers give us tracks in all kinds of structures, so this cleans things first
if (action.records_type == 'track') {
records = formatTracks(records);
}
var records_type_plural = `${action.records_type}s`;
var records_index = {};
var records_uris = arrayOf('uri', records);
// Merge any extra data (eg more_track's albums)
if (action.extra_data) {
records = records.map(record => ({ ...record, ...action.extra_data }));
}
// If we're a list of playlists, we need to manually filter Spotify's new URI structure
// Really poor form because they haven't updated it everywhere, yet
if (action.records_type == 'playlist') {
records_uris = upgradeSpotifyPlaylistUris(records_uris);
}
// Append our parent object's reference to these records
var uris = records_uris;
if (parent[`${records_type_plural}_uris`] !== undefined) {
uris = [...parent[`${records_type_plural}_uris`], ...uris];
}
parent[`${records_type_plural}_uris`] = uris;
if (action.records_data.next !== undefined) {
parent[`${records_type_plural}_more`] = action.records_data.next;
}
// Parent loaded (well, changed)
var parent_action = {
type: `${parent_type_plural.toUpperCase()}_LOADED`,
};
parent_action[parent_type_plural] = [parent];
store.dispatch(parent_action);
// Records loaded
var records_action = {
type: `${records_type_plural.toUpperCase()}_LOADED`,
};
records_action[records_type_plural] = records;
store.dispatch(records_action);
next(action);
break;
case 'ADD_PINNED':
store.dispatch(coreActions.updatePinned([
...store.getState().core.pinned,
formatSimpleObject(action.item),
]));
next(action);
break;
case 'REMOVE_PINNED':
store.dispatch(coreActions.updatePinned(
store.getState().core.pinned.filter((pinnedItem) => pinnedItem.uri !== action.uri),
));
next(action);
break;
case 'UPDATE_PINNED_URI':
store.dispatch(coreActions.updatePinned(
store.getState().core.pinned.map((pinnedItem) => ({
...pinnedItem,
...(pinnedItem.uri === action.oldUri ? { uri: action.newUri } : {}),
})),
));
next(action);
break;
case 'RESTORE_ITEMS_FROM_COLD_STORE':
store.dispatch(uiActions.stopLoading(arrayOf('uri', action.items)));
next(action);
break;
case 'RESTORE_LIBRARY_FROM_COLD_STORE':
store.dispatch(uiActions.stopLoading(action.library.uri));
next(action);
break;
// TODO: Relocate this
case 'UPDATE_COLD_STORE':
if (action.items) {
action.items.map((item) => {
localForage.getItem(item.uri).then((result) => {
localForage.setItem(item.uri, { ...(result || {}), ...item });
});
});
}
break;
// This action is irrelevant to us, pass it on to the next middleware
default:
return next(action);
}
};
}());
export default CoreMiddleware;