Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/js/components/Fields/OutputControl.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ const OutputControl = ({ force_expanded }) => {
if (expanded) {
return (
<span className="output-control">
{!force_expanded && <div className="click-outside" onClick={() => setExpanded(false)} />}
<div className="click-outside" onClick={() => setExpanded(false)} />
<button
className="control speakers active"
onClick={() => setExpanded(false)}
Expand Down
16 changes: 16 additions & 0 deletions src/js/locale/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ errors:
cannot_reorder:
title: Cannot reorder items
description: Remove sort/filters and try again
invalid_response: The server returned an empty or invalid response. This usually means the request failed partway through, your session expired, or the connection was interrupted.
network_unreachable: Could not reach the server. Check your network connection.
authorization_expired: Your authorization for this service has expired or been revoked. Try reconnecting it in Settings.
request_timeout: The request took too long and timed out.
http_status:
0: Could not reach the server. Check your network connection, or that the server is running.
400: The request was invalid or rejected by the server.
401: You are not authenticated, or your session has expired.
403: You do not have permission to do that.
404: The requested resource could not be found. It may have been removed or renamed.
408: The request timed out.
429: Too many requests were sent in a short period. Wait a moment and try again.
500: The server encountered an internal error.
502: The server is temporarily unavailable (bad gateway).
503: The server is temporarily unavailable.
504: The server took too long to respond (gateway timeout).
actions:
play: Play
play_all: Play all
Expand Down
9 changes: 6 additions & 3 deletions src/js/services/core/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ export function handleException(message = '', data = {}, description = null, sho
if (!message) {
if (data.message) {
message = data.message;
} else if (data.error.message) {
} else if (data.error?.message) {
message = data.error.message;
}
}
if (!description) {
if (data.description) {
description = data.description;
} else if (data.error && data.error.message) {
} else if (data.error?.message) {
description = data.error.message;
} else if (data.error && data.error.description) {
} else if (data.error?.description) {
description = data.error.description;
} else if (data.message && data.message !== message) {
// Covers plain Error/SyntaxError objects passed straight through as `data`
description = data.message;
}
}

Expand Down
18 changes: 15 additions & 3 deletions src/js/services/core/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
titleCase,
} from '../../util/helpers';
import { ensureLoaded } from '../../util/library';
import { humanizeError } from '../../util/errors';
import {
formatTracks,
formatTrack,
Expand Down Expand Up @@ -70,15 +71,26 @@ const CoreMiddleware = (function () {
// 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;
try {
const xhr_response = JSON.parse(action.data.xhr.responseText);
if (xhr_response.error?.message) {
description = xhr_response.error.message;
}
} catch (e) {
// Response wasn't JSON; fall through to the status line below
}
if (!description) {
description = `${action.data.xhr.status} ${action.data.xhr.statusText}`;
}
} else if (action.data.xhr) {
description = `${action.data.xhr.status} ${action.data.xhr.statusText}`;
}
}

// Add plain-language context to descriptions that are otherwise just
// a bare exception message or HTTP status line
description = humanizeError(description, action.data);

// Strip out non-essential store info
delete exported_state.core.albums;
delete exported_state.core.artists;
Expand Down
34 changes: 32 additions & 2 deletions src/js/services/spotify/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ const request = ({
},
(error) => {
dispatch(coreActions.handleException(
error.error,
'Could not refresh your Spotify authorization',
error,
'This usually means your Spotify authorization has expired or been revoked. Try reconnecting Spotify in Settings.',
));

reject(error);
Expand Down Expand Up @@ -329,8 +331,9 @@ export function getMe() {
},
(error) => {
dispatch(coreActions.handleException(
'Could not load your profile',
'Could not load your Spotify profile',
error,
'This usually means your Spotify authorization has expired or is invalid. Try reconnecting Spotify in Settings.',
));
},
);
Expand Down Expand Up @@ -467,6 +470,10 @@ export function getLibraryMoods(forceRefetch) {
items_uris: arrayOf('uri', libraryItems),
}));
}
})
.catch((error) => {
dispatch(uiActions.processFinished(processKey));
dispatch(coreActions.handleException('Could not load library moods', error));
});

let endpoint = `browse/categories?limit=50&country=${country}&locale=${locale}`;
Expand Down Expand Up @@ -641,6 +648,12 @@ export function getSearchResults(query, limit = 50, offset = 0) {
request({ dispatch, getState, endpoint })
.then(
(response) => {
const processor = getState().ui.processes[processKey];
if (processor && processor.status === 'cancelling') {
dispatch(uiActions.processCancelled(processKey));
return;
}

if (response.tracks !== undefined) {
dispatch(coreActions.searchResultsLoaded(
getSearchResultKey({ provider: 'spotify', type: 'tracks', term }),
Expand Down Expand Up @@ -676,6 +689,7 @@ export function getSearchResults(query, limit = 50, offset = 0) {
dispatch(uiActions.processFinished(processKey));
},
(error) => {
dispatch(uiActions.processFinished(processKey));
dispatch(coreActions.handleException(
'Could not load search results',
error,
Expand Down Expand Up @@ -1730,6 +1744,10 @@ export function getLibraryPlaylists(forceRefetch) {
items_uris: arrayOf('uri', libraryItems),
}));
}
})
.catch((error) => {
dispatch(uiActions.processFinished(processKey));
dispatch(coreActions.handleException('Could not load library playlists', error));
});

fetch(`me/playlists?limit=50${forceRefetch ? `&refetch=${Date.now()}` : ''}`);
Expand Down Expand Up @@ -1775,6 +1793,10 @@ export function getLibraryAlbums(forceRefetch) {
items_uris: arrayOf('uri', libraryItems),
}));
}
})
.catch((error) => {
dispatch(uiActions.processFinished(processKey));
dispatch(coreActions.handleException('Could not load library albums', error));
});

fetch(`me/albums?limit=50${forceRefetch ? `&refetch=${Date.now()}` : ''}`);
Expand Down Expand Up @@ -1822,6 +1844,10 @@ export function getLibraryArtists(forceRefetch) {
items_uris: arrayOf('uri', libraryItems),
}));
}
})
.catch((error) => {
dispatch(uiActions.processFinished(processKey));
dispatch(coreActions.handleException('Could not load library artists', error));
});

fetch(`me/following?type=artist&limit=50${forceRefetch ? `&refetch=${Date.now()}` : ''}`);
Expand Down Expand Up @@ -1866,6 +1892,10 @@ export function getLibraryTracks(forceRefetch) {
items_uris: arrayOf('uri', libraryItems),
}));
}
})
.catch((error) => {
dispatch(uiActions.processFinished(processKey));
dispatch(coreActions.handleException('Could not load library tracks', error));
});

fetch(`me/tracks?limit=50${forceRefetch ? `&refetch=${Date.now()}` : ''}`);
Expand Down
56 changes: 56 additions & 0 deletions src/js/util/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { i18n } from '../locale';

// Common failure signatures that are technically accurate but meaningless to
// a non-technical reader, mapped to the translation key holding a
// plain-language explanation of what likely happened.
const PATTERN_EXPLANATIONS = [
{
pattern: /unexpected end of (json )?input|json\.parse|unexpected token/i,
key: 'errors.invalid_response',
},
{
pattern: /failed to fetch|networkerror|network request failed|load failed|err_internet_disconnected|err_connection|err_name_not_resolved/i,
key: 'errors.network_unreachable',
},
{
pattern: /invalid_grant|refresh token expired|grant has been revoked/i,
key: 'errors.authorization_expired',
},
{
pattern: /timed? ?out/i,
key: 'errors.request_timeout',
},
];

const extractStatus = (data = {}) => {
if (data.xhr && typeof data.xhr.status === 'number') return data.xhr.status;
if (typeof data.status === 'number') return data.status;
return null;
};

/**
* Takes a raw error description (often a bare exception message or HTTP
* status line) and, where we recognise the shape of the problem, prefixes it
* with a plain-language explanation of the likely cause. Falls back to the
* original description untouched when nothing is recognised, so this is
* always safe to apply.
*/
export function humanizeError(description, data = {}) {
if (!description || typeof description !== 'string') return description;

const matchedPattern = PATTERN_EXPLANATIONS.find(({ pattern }) => pattern.test(description));
if (matchedPattern) {
const explanation = i18n(matchedPattern.key);
if (explanation) return `${explanation} (${description})`;
}

const status = extractStatus(data);
if (status !== null) {
const explanation = i18n(`errors.http_status.${status}`);
if (explanation) return `${explanation} (${description})`;
}

return description;
}

export default humanizeError;
4 changes: 3 additions & 1 deletion src/scss/components/_output-control.scss
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@
padding: 0;
border-radius: 0;
max-height: calc(100vh - 140px);
overflow-y: visible;
max-height: calc(100dvh - 140px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;

@include theme('dark') {
box-shadow: 0 -5px 5px rgba(0, 0, 0, 0.6);
Expand Down
62 changes: 30 additions & 32 deletions src/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,41 +26,39 @@ self.addEventListener('fetch', (event) => {
const { request } = event;

event.respondWith(
caches.open('iris').then((cache) => fetch(request)
// Network-first: always prefer a live response so app updates (new
// JS/CSS bundles, fresh API data) are visible immediately, rather
// than being masked by a stale cache indefinitely. This cache is
// now purely an offline fallback, not a source of truth.
.then((liveResponse) => {
const isBlacklisted = inBlacklist(request.url);

// Opens Cache objects that start with 'font'.
caches.open('iris').then((cache) => cache.match(request)
.then((response) => {
if (response) {
return response;
}

// Not cached, so we make the request, return that and also save response in cache
return fetch(request)
.then((liveResponse) => {
const isBlacklisted = inBlacklist(request.url);

// Only cache successful GET requests
if (
!isBlacklisted
&& request.method === 'GET'
&& liveResponse.status >= 200
&& liveResponse.status < 400
) {
// Fixes Edge browser "'chrome-extension' is unsupported" issue
// See https://stackoverflow.com/questions/49157622/service-worker-typeerror-when-opening-chrome-extension
if (!/^https?:$/i.test(new URL(request.url).protocol)) return;
// Only cache successful GET requests
if (
!isBlacklisted
&& request.method === 'GET'
&& liveResponse.status >= 200
&& liveResponse.status < 400
) {
// Fixes Edge browser "'chrome-extension' is unsupported" issue
// See https://stackoverflow.com/questions/49157622/service-worker-typeerror-when-opening-chrome-extension
if (!/^https?:$/i.test(new URL(request.url).protocol)) return liveResponse;

cache.put(request, liveResponse.clone());
} else {
console.info(`Not caching ${isBlacklisted ? '(blacklisted) ' : ''}${request.method} ${request.url}`);
}
cache.put(request, liveResponse.clone());
} else {
console.info(`Not caching ${isBlacklisted ? '(blacklisted) ' : ''}${request.method} ${request.url}`);
}

return liveResponse;
});
return liveResponse;
})
// Network unavailable (offline): fall back to whatever we have cached.
.catch(() => cache.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}

// Exceptions from match() or fetch()
}).catch((error) => {
throw error;
})),
throw new Error(`Network request failed and no cached response is available for ${request.url}`);
}))),
);
});