Skip to content

Commit

Permalink
Merge pull request #1251 from nukeop/fix/881
Browse files Browse the repository at this point in the history
Allow importing more than 1000 last.fm tracks
  • Loading branch information
nukeop authored Apr 11, 2022
2 parents c5f6be6 + 94144fe commit 44c82da
Show file tree
Hide file tree
Showing 11 changed files with 256 additions and 117 deletions.
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

0 comments on commit 44c82da

Please sign in to comment.