Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow importing more than 1000 last.fm tracks #1251

Merged
merged 2 commits into from
Apr 11, 2022
Merged
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
43 changes: 12 additions & 31 deletions packages/app/__mocks__/@nuclear/core.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,14 @@
const initialStoreState = () => ({
equalizer: {
selected: 'Default'
},
downloads: [],
favorites: {
albums: [],
tracks: []
},
playlists: []
});
import { initialStoreState, mockElectronStore } from '../../test/mockElectronStore';

let mockStore = {...initialStoreState()};
const mockStore = {...initialStoreState()};

const LastFmApi = {
searchTracks: jest.fn().mockResolvedValue([])
};

module.exports = {
isArtistObject: jest.requireActual('@nuclear/core/src/types').isArtistObject,
store: {
init: (store: typeof mockStore) => mockStore = store,
get: (key: string) => mockStore[key] || {},
set: (key: string, value: any) => {
mockStore[key] = value;
},
clear: () => mockStore = initialStoreState()
},
store: mockElectronStore(mockStore),
createApi: () => ({
app: {},
store: {},
Expand All @@ -35,17 +18,15 @@ module.exports = {
setOption: jest.fn(),
getOption: () => '',
rest: {
LastFmApi: class {
constructor() {}

getTagInfo() {}
getTagTracks() {}
getTagAlbums() {}
getTagArtists() {}
getTopTags = jest.fn().mockResolvedValue([])
getTopTracks = jest.fn().mockResolvedValue([])
searchTracks = LastFmApi.searchTracks
},
LastFmApi: jest.fn(() => ({
getTagInfo() {},
getTagTracks() {},
getTagAlbums() {},
getTagArtists() {},
getTopTags: jest.fn().mockResolvedValue([]),
getTopTracks: jest.fn().mockResolvedValue([]),
searchTracks: LastFmApi.searchTracks
})),
Youtube: {
urlSearch: jest.fn().mockResolvedValue([]),
liveStreamSearch: jest.fn().mockResolvedValue([])
Expand Down
5 changes: 2 additions & 3 deletions packages/app/app/actions/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logger from 'electron-timber';
import { rest } from '@nuclear/core';
import { getBestNewAlbums, getBestNewTracks } from 'pitchfork-bnm';
import { Deezer } from '@nuclear/core/src/rest';

import globals from '../globals';
import { Dashboard } from './actionTypes';
Expand Down Expand Up @@ -84,8 +83,8 @@ export const loadTopTracks = () => async (dispatch) => {
dispatch(loadTopTracksAction.request());

try {
const tracks = await Deezer.getTopTracks();
dispatch(loadTopTracksAction.success(tracks.data.map(Deezer.mapDeezerTrackToInternal)));
const tracks = await rest.Deezer.getTopTracks();
dispatch(loadTopTracksAction.success(tracks.data.map(rest.Deezer.mapDeezerTrackToInternal)));
} catch (error) {
dispatch(loadTopTracksAction.failure());
logger.error(error);
Expand Down
22 changes: 17 additions & 5 deletions packages/app/app/actions/favorites.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import _, { flow } from 'lodash';
import { store } from '@nuclear/core';
import _, { flow, unionWith } from 'lodash';
import { store, Track } from '@nuclear/core';
import { areTracksEqualByName, getTrackItem, removeTrackStreamUrl } from '@nuclear/ui';

import { safeAddUuid } from './helpers';
import { createStandardAction } from 'typesafe-actions';

export const READ_FAVORITES = 'READ_FAVORITES';
export const ADD_FAVORITE_TRACK = 'ADD_FAVORITE_TRACK';
export const REMOVE_FAVORITE_TRACK = 'REMOVE_FAVORITE_TRACK';
export const BULK_ADD_FAVORITE_TRACKS = 'BULK_ADD_FAVORITE_TRACKS';

export const ADD_FAVORITE_ALBUM = 'ADD_FAVORITE_ALBUM';
export const REMOVE_FAVORITE_ALBUM = 'REMOVE_FAVORITE_ALBUM';
Expand All @@ -24,19 +26,29 @@ export function readFavorites() {

export function addFavoriteTrack(track) {
const clonedTrack = flow(safeAddUuid, getTrackItem, removeTrackStreamUrl)(track);

const favorites = store.get('favorites');
const filteredTracks = favorites.tracks.filter(t => !areTracksEqualByName(t, track));
favorites.tracks = [...filteredTracks, clonedTrack];

store.set('favorites', favorites);

return {
type: ADD_FAVORITE_TRACK,
payload: favorites
};
}

const bulkAddFavoriteTracksAction = createStandardAction(BULK_ADD_FAVORITE_TRACKS)<Track[]>();

export const bulkAddFavoriteTracks = (tracks: Track[]) => {
const favorites = store.get('favorites');
favorites.tracks = unionWith(favorites.tracks, tracks, areTracksEqualByName);
store.set('favorites', favorites);

return bulkAddFavoriteTracksAction(favorites);
};

export function removeFavoriteTrack(track) {
const favorites = store.get('favorites');
favorites.tracks = favorites.tracks.filter(t => !areTracksEqualByName(t, track));
Expand Down
60 changes: 26 additions & 34 deletions packages/app/app/actions/importfavs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import globals from '../globals';
import * as FavoritesActions from './favorites';
import { ImportFavs } from './actionTypes';

const lastfm = new rest.LastFmApi(globals.lastfmApiKey, globals.lastfmApiSecret);

const MAX_TRACKS_PER_PAGE = 1000;
export function FavImportInit() {
return {
type: ImportFavs.FAV_IMPORT_INIT,
Expand Down Expand Up @@ -43,41 +42,34 @@ function FmSuccessFinal(count) {
export function fetchAllFmFavorites() {
const storage = store.get('lastFm');
if (storage) {
return dispatch => {
return async dispatch => {
dispatch({ type: ImportFavs.LASTFM_FAV_IMPORT_START });
lastfm.getNumberOfLovedTracks(storage.lastFmName, 1)
.then((resp) => resp.json())
.then(req => {
if (!req.lovedtracks) {
throw new Error;
}
const totalLovedTracks = req.lovedtracks['@attr'].total;
if (totalLovedTracks <= 0 || totalLovedTracks > 1000) {
throw totalLovedTracks;
}
dispatch(FmSuccess1(totalLovedTracks));
return lastfm.getNumberOfLovedTracks(storage.lastFmName, totalLovedTracks);
})
.then((resp) => resp.json())
.then((req) => {
if (!req.lovedtracks || !req.lovedtracks.track) {
try {
const lastfm = new rest.LastFmApi(globals.lastfmApiKey, globals.lastfmApiSecret);
const numberOfTracksResponse = await(await lastfm.getLovedTracks(storage.lastFmName, 1)).json();

if (!numberOfTracksResponse.lovedtracks) {
throw new Error;
}
const totalLovedTracks = Number.parseInt(numberOfTracksResponse.lovedtracks['@attr'].total);
const totalPages = Math.ceil(totalLovedTracks / MAX_TRACKS_PER_PAGE);
dispatch(FmSuccess1(totalLovedTracks));
let lovedTracks = [];
for (let i = 1; i <= totalPages; i++) {
const lovedTracksResponse = await(await lastfm.getLovedTracks(storage.lastFmName, Math.min(MAX_TRACKS_PER_PAGE, totalLovedTracks), i)).json();
if (!lovedTracksResponse.lovedtracks) {
throw new Error;
}
req.lovedtracks.track.forEach(favtrack => {
FavoritesActions.addFavoriteTrack(favtrack);
});
dispatch(FmSuccessFinal(req.lovedtracks.track.length));
})
.catch((error) => {
if (error <= 0 || error > 1000) {
dispatch(FmFavError(' Invalid number of favorites [' + error + ']'));
} else {
dispatch(FmFavError());
logger.error(error);
}
});
lovedTracks = [...lovedTracks, ...lovedTracksResponse.lovedtracks.track];
}

dispatch(FavoritesActions.bulkAddFavoriteTracks(lovedTracks));

dispatch(FmSuccessFinal(lovedTracks.length));
} catch (error) {
dispatch(FmFavError(error.message));
logger.error(error);
}
};
} else {
return FmFavError();
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,121 @@
import { buildStoreState } from '../../../test/storeBuilders';
import { mountedComponentFactory, setupI18Next } from '../../../test/testUtils';
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { waitFor } from '@testing-library/react';import fetchMock from 'fetch-mock';
import { store as electronStore } from '@nuclear/core';

import { buildElectronStoreState, buildStoreState } from '../../../test/storeBuilders';
import { AnyProps, mountedComponentFactory, setupI18Next } from '../../../test/testUtils';
import { range } from 'lodash';

jest.mock('../../globals', () => ({
lastfmApiKey: 'last_fm_key',
lastfmApiSecret: 'last_fm_secret'
}));

describe('Settings view container', () => {
beforeAll(() => {
setupI18Next();
});

beforeEach(() => {
fetchMock.reset();
electronStore.clear();
process.env.LAST_FM_API_KEY = 'last_fm_key';
process.env.LAST_FM_API_SECRET = 'last_fm_secret';
});

it('should render settings', () => {
const { component } = mountComponent();
expect(component.asFragment()).toMatchSnapshot();
});

const mountComponent = mountedComponentFactory(
['/settings'],
buildStoreState()
.withConnectivity()
.build()
);
it('should import last.fm favorites (less than 1000)', async () => {
const coreMock = require('@nuclear/core');
const LastFmApi = jest.requireActual('@nuclear/core/src/rest/Lastfm');
coreMock.rest.LastFmApi.mockImplementation(
(key, secret) =>
new LastFmApi.default(key, secret)
);
const totalTracks = 3;
const tracksFromLastfm = createTracksFromLastfm(totalTracks);

withNumberOfTracks(totalTracks);
withLovedTracks(3, 1, totalTracks, tracksFromLastfm);

const { component, store } = mountComponent({
lastFm: {
lastFmName: 'nuclear'
}
});
await waitFor(() => component.getByText('Import').click());
const state = store.getState();

expect(state.favorites.tracks).toEqual(tracksFromLastfm);
expect(electronStore.get('favorites')).toEqual(expect.objectContaining({
tracks: tracksFromLastfm
}));
});

it('should import last.fm favorites (more than 1000)', async () => {
const coreMock = require('@nuclear/core');
const LastFmApi = jest.requireActual('@nuclear/core/src/rest/Lastfm');
coreMock.rest.LastFmApi.mockImplementation(
(key, secret) =>
new LastFmApi.default(key, secret)
);
const totalTracks = 4500;
const tracksFromLastfm = createTracksFromLastfm(totalTracks);

withNumberOfTracks(totalTracks);
withLovedTracks(1000, 1, totalTracks, tracksFromLastfm.slice(0, 1000));
withLovedTracks(1000, 2, totalTracks, tracksFromLastfm.slice(1000, 2000));
withLovedTracks(1000, 3, totalTracks, tracksFromLastfm.slice(2000, 3000));
withLovedTracks(1000, 4, totalTracks, tracksFromLastfm.slice(3000, 4000));
withLovedTracks(1000, 5, totalTracks, tracksFromLastfm.slice(4000, 4500));

const { component, store } = mountComponent({
lastFm: {
lastFmName: 'nuclear'
}
});
await waitFor(() => component.getByText('Import').click());
const state = store.getState();

expect(state.favorites.tracks).toEqual(tracksFromLastfm);
expect(electronStore.get('favorites')).toEqual(expect.objectContaining({
tracks: tracksFromLastfm
}));
});

const mountComponent = (electronStoreState?: AnyProps) => {
// @ts-ignore
electronStore.init({
...buildElectronStoreState(electronStoreState)
});

return mountedComponentFactory(
['/settings'],
buildStoreState()
.withConnectivity()
.build()
)();
};

const createTracksFromLastfm = (totalTracks: number) => range(totalTracks).map(num => ({
artist: `artist ${num}`,
name: `track ${num}`
}));

const tracksApiResponse = (track: object[], totalTracks: number) => ({
lovedtracks: {
track,
'@attr': {
total: totalTracks.toString()
}
}
});

const withNumberOfTracks = (totalTracks: number) => fetchMock.post('https://ws.audioscrobbler.com/2.0/?method=user.getlovedtracks&user=nuclear&format=json&limit=1&page=1&api_key=last_fm_key', tracksApiResponse([], totalTracks));

const withLovedTracks = (limit: number, page: number, totalTracks: number, tracks: object[]) => fetchMock.post(`https://ws.audioscrobbler.com/2.0/?method=user.getlovedtracks&user=nuclear&format=json&limit=${limit}&page=${page}&api_key=last_fm_key`, tracksApiResponse(tracks, totalTracks));
});
13 changes: 8 additions & 5 deletions packages/app/app/reducers/favorites.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
REMOVE_FAVORITE_ALBUM,

ADD_FAVORITE_ARTIST,
REMOVE_FAVORITE_ARTIST
REMOVE_FAVORITE_ARTIST,
BULK_ADD_FAVORITE_TRACKS
} from '../actions/favorites';

const initialState = {
Expand All @@ -16,18 +17,20 @@ const initialState = {
albums: []
};

export default function FavoritesReducer(state=initialState, action) {
const FavoritesReducer = (state = initialState, action) => {
switch (action.type) {
case READ_FAVORITES:
return Object.assign({}, action.payload);
case ADD_FAVORITE_TRACK:
case REMOVE_FAVORITE_TRACK:
case ADD_FAVORITE_ALBUM:
case REMOVE_FAVORITE_ALBUM:
case ADD_FAVORITE_ARTIST:
case REMOVE_FAVORITE_ARTIST:
return Object.assign({}, action.payload);
case BULK_ADD_FAVORITE_TRACKS:
return { ...action.payload };
default:
return state;
}
}
};

export default FavoritesReducer;
22 changes: 22 additions & 0 deletions packages/app/test/mockElectronStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const initialStoreState = () => ({
equalizer: {
selected: 'Default'
},
downloads: [],
favorites: {
albums: [],
tracks: []
},
playlists: []
});

export type MockStore = ReturnType<typeof initialStoreState>;

export const mockElectronStore = (mockStore: MockStore) => ({
init: (store: typeof mockStore) => mockStore = store,
get: (key: string) => mockStore[key] || {},
set: (key: string, value: any) => {
mockStore[key] = value;
},
clear: () => mockStore = initialStoreState()
});
Loading