Phyks (Lucas Verney)
d8a7d4f66a
Full rework of webplayer. Webplayer is back to its previous working state, and ready for further improvements.
303 lines
9.3 KiB
JavaScript
303 lines
9.3 KiB
JavaScript
/**
|
|
* Redux middleware to perform API queries.
|
|
*
|
|
* This middleware catches the API requests and replaces them with API
|
|
* responses.
|
|
*/
|
|
import fetch from "isomorphic-fetch";
|
|
import humps from "humps";
|
|
import X2JS from "x2js";
|
|
|
|
import { assembleURLAndParams } from "../utils";
|
|
import { i18nRecord } from "../models/i18n";
|
|
|
|
import { loginUserExpired } from "../actions/auth";
|
|
|
|
export const API_VERSION = 350001; /** API version to use. */
|
|
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */
|
|
|
|
// Action key that carries API call info interpreted by this Redux middleware.
|
|
export const CALL_API = "CALL_API";
|
|
|
|
// Error class to represents errors from these actions.
|
|
class APIError extends Error {}
|
|
|
|
|
|
/**
|
|
* Check the HTTP status of the response.
|
|
*
|
|
* @param response A XHR response object.
|
|
* @return The response or a rejected Promise if the check failed.
|
|
*/
|
|
function _checkHTTPStatus(response) {
|
|
if (response.status >= 200 && response.status < 300) {
|
|
return response;
|
|
} else {
|
|
return Promise.reject(response.statusText);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Parse the XML resulting from the API to JS object.
|
|
*
|
|
* @param responseText The text from the API response.
|
|
* @return The response as a JS object or a rejected Promise on error.
|
|
*/
|
|
function _parseToJSON(responseText) {
|
|
let x2js = new X2JS({
|
|
attributePrefix: "", // No prefix for attributes
|
|
keepCData: false, // Do not store __cdata and toString functions
|
|
});
|
|
if (responseText) {
|
|
return x2js.xml_str2json(responseText).root;
|
|
}
|
|
return Promise.reject(new i18nRecord({
|
|
id: "app.api.invalidResponse",
|
|
values: {},
|
|
}));
|
|
}
|
|
|
|
|
|
/**
|
|
* Check the errors returned by the API itself, in its response.
|
|
*
|
|
* @param jsonData A JS object representing the API response.
|
|
* @return The input data or a rejected Promise if errors are present.
|
|
*/
|
|
function _checkAPIErrors(jsonData) {
|
|
if (jsonData.error) {
|
|
return Promise.reject(jsonData.error);
|
|
} else if (!jsonData) {
|
|
// No data returned
|
|
return Promise.reject(new i18nRecord({
|
|
id: "app.api.emptyResponse",
|
|
values: {},
|
|
}));
|
|
}
|
|
return jsonData;
|
|
}
|
|
|
|
|
|
/**
|
|
* Apply some fixes on the API data.
|
|
*
|
|
* @param jsonData A JS object representing the API response.
|
|
* @return A fixed JS object.
|
|
*/
|
|
function _uglyFixes(jsonData) {
|
|
// Fix songs array
|
|
let _uglyFixesSongs = function (songs) {
|
|
return songs.map(function (song) {
|
|
// Fix for cdata left in artist and album
|
|
song.artist.name = song.artist.cdata;
|
|
song.album.name = song.album.cdata;
|
|
return song;
|
|
});
|
|
};
|
|
|
|
// Fix albums array
|
|
let _uglyFixesAlbums = function (albums) {
|
|
return albums.map(function (album) {
|
|
// TODO: Should go in Ampache core
|
|
// Fix for absence of distinction between disks in the same album
|
|
if (album.disk > 1) {
|
|
album.name = album.name + " [Disk " + album.disk + "]";
|
|
}
|
|
|
|
// Move songs one node top
|
|
if (album.tracks.song) {
|
|
album.tracks = album.tracks.song;
|
|
|
|
// Ensure tracks is an array
|
|
if (!Array.isArray(album.tracks)) {
|
|
album.tracks = [album.tracks];
|
|
}
|
|
|
|
// Fix tracks array
|
|
album.tracks = _uglyFixesSongs(album.tracks);
|
|
}
|
|
return album;
|
|
});
|
|
};
|
|
|
|
// Fix artists array
|
|
let _uglyFixesArtists = function (artists) {
|
|
return artists.map(function (artist) {
|
|
// Move albums one node top
|
|
if (artist.albums.album) {
|
|
artist.albums = artist.albums.album;
|
|
|
|
// Ensure albums are an array
|
|
if (!Array.isArray(artist.albums)) {
|
|
artist.albums = [artist.albums];
|
|
}
|
|
|
|
// Fix albums
|
|
artist.albums = _uglyFixesAlbums(artist.albums);
|
|
}
|
|
|
|
// Move songs one node top
|
|
if (artist.songs.song) {
|
|
artist.songs = artist.songs.song;
|
|
|
|
// Ensure songs are an array
|
|
if (!Array.isArray(artist.songs)) {
|
|
artist.songs = [artist.songs];
|
|
}
|
|
|
|
// Fix songs
|
|
artist.songs = _uglyFixesSongs(artist.songs);
|
|
}
|
|
return artist;
|
|
});
|
|
};
|
|
|
|
// Ensure items are always wrapped in an array
|
|
if (jsonData.artist && !Array.isArray(jsonData.artist)) {
|
|
jsonData.artist = [jsonData.artist];
|
|
}
|
|
if (jsonData.album && !Array.isArray(jsonData.album)) {
|
|
jsonData.album = [jsonData.album];
|
|
}
|
|
if (jsonData.song && !Array.isArray(jsonData.song)) {
|
|
jsonData.song = [jsonData.song];
|
|
}
|
|
|
|
// Fix artists
|
|
if (jsonData.artist) {
|
|
jsonData.artist = _uglyFixesArtists(jsonData.artist);
|
|
}
|
|
|
|
// Fix albums
|
|
if (jsonData.album) {
|
|
jsonData.album = _uglyFixesAlbums(jsonData.album);
|
|
}
|
|
|
|
// Fix songs
|
|
if (jsonData.song) {
|
|
jsonData.song = _uglyFixesSongs(jsonData.song);
|
|
}
|
|
|
|
// TODO: Should go in Ampache core
|
|
// Add sessionExpire information
|
|
if (!jsonData.sessionExpire) {
|
|
// Fix for Ampache not returning updated sessionExpire
|
|
jsonData.sessionExpire = (new Date(Date.now() + 3600 * 1000)).toJSON();
|
|
}
|
|
|
|
return jsonData;
|
|
}
|
|
|
|
|
|
/**
|
|
* Fetches an API response and normalizes the result.
|
|
*
|
|
* @param endpoint Base URL of your Ampache server.
|
|
* @param action API action name.
|
|
* @param auth API token to use.
|
|
* @param username Username to use in the API.
|
|
* @param extraParams An object of extra parameters to pass to the API.
|
|
*
|
|
* @return A fetching Promise.
|
|
*/
|
|
function doAPICall(endpoint, action, auth, username, extraParams) {
|
|
// Translate the API action to real API action
|
|
const APIAction = extraParams.filter ? action.rstrip("s") : action;
|
|
// Set base params
|
|
const baseParams = {
|
|
version: API_VERSION,
|
|
action: APIAction,
|
|
auth: auth,
|
|
user: username,
|
|
};
|
|
// Extend with extraParams
|
|
const params = Object.assign({}, baseParams, extraParams);
|
|
// Assemble the full URL with endpoint, API path and GET params
|
|
const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
|
|
|
|
return fetch(fullURL, {
|
|
method: "get",
|
|
})
|
|
.then(_checkHTTPStatus)
|
|
.then (response => response.text())
|
|
.then(_parseToJSON)
|
|
.then(_checkAPIErrors)
|
|
.then(jsonData => humps.camelizeKeys(jsonData)) // Camelize
|
|
.then(_uglyFixes);
|
|
}
|
|
|
|
|
|
/**
|
|
* A Redux middleware that interprets actions with CALL_API info specified.
|
|
* Performs the call and promises when such actions are dispatched.
|
|
*/
|
|
export default store => next => reduxAction => {
|
|
if (reduxAction.type !== CALL_API) {
|
|
// Do not apply on other actions
|
|
return next(reduxAction);
|
|
}
|
|
|
|
// Check payload
|
|
const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
|
|
if (!endpoint || typeof endpoint !== "string") {
|
|
throw new APIError("Specify a string endpoint URL.");
|
|
}
|
|
if (!action) {
|
|
throw new APIError("Specify one of the supported API actions.");
|
|
}
|
|
if (!auth) {
|
|
throw new APIError("Specify an auth token.");
|
|
}
|
|
if (!username) {
|
|
throw new APIError("Specify a username.");
|
|
}
|
|
if (!Array.isArray(dispatch) || dispatch.length !== 3) {
|
|
throw new APIError("Expected an array of three action dispatch.");
|
|
}
|
|
if (!dispatch.every(type => typeof type === "function" || type === null)) {
|
|
throw new APIError("Expected action to dispatch to be functions or null.");
|
|
}
|
|
|
|
// Get the actions to dispatch
|
|
const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;
|
|
if (requestDispatch) {
|
|
// Dispatch request action if needed
|
|
store.dispatch(requestDispatch());
|
|
}
|
|
|
|
// Run the API call
|
|
return doAPICall(endpoint, action, auth, username, extraParams).then(
|
|
response => {
|
|
if (successDispatch) {
|
|
// Dispatch success if needed
|
|
store.dispatch(successDispatch(response));
|
|
}
|
|
},
|
|
error => {
|
|
if (failureDispatch) {
|
|
// Error object from the API (in the JS object)
|
|
if (error._code && error.__cdata) {
|
|
// Format the error message
|
|
const errorMessage = error.__cdata + " (" + error._code + ")";
|
|
if (401 == error._code) {
|
|
// This is an error meaning no valid session was
|
|
// passed. We must perform a new handshake.
|
|
store.dispatch(loginUserExpired(errorMessage));
|
|
return;
|
|
}
|
|
// Else, form error message and continue
|
|
error = errorMessage;
|
|
}
|
|
// Else if exception was thrown
|
|
else if (error instanceof Error) {
|
|
// Form error message and continue
|
|
error = error.message;
|
|
}
|
|
// Dispatch a failure event
|
|
store.dispatch(failureDispatch(error));
|
|
}
|
|
}
|
|
);
|
|
};
|