Skip to content

Commit 6daffcf

Browse files
Auto-fill album for artist and track (#250)
Closes #88 --------- Co-authored-by: Enrico Lamperti <[email protected]>
1 parent 74188a8 commit 6daffcf

File tree

6 files changed

+174
-9
lines changed

6 files changed

+174
-9
lines changed

public/locales/en.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -162,5 +162,7 @@
162162
"connect": "Connect",
163163
"General settings title": {
164164
"general": "General"
165-
}
165+
},
166+
"autoFillAlbum": "Fill album name from last.fm",
167+
"autoFillAlbumInvalidForm": "Enter artist and track for album auto-fill"
166168
}

public/locales/ru.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,7 @@
160160
"connect": "Connect",
161161
"General settings title": {
162162
"general": "General"
163-
}
163+
},
164+
"autoFillAlbum": "Заполнить альбом с last.fm",
165+
"autoFillAlbumInvalidForm": "Введите имя исполнителя и трек, чтобы автозаполнить альбом"
164166
}

src/domains/scrobbleSong/SongForm.css

+58-2
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,24 @@ Label.required::after {
1515
width: calc(100% + 15px);
1616
}
1717

18-
input.hasLock {
18+
input.has-button {
1919
padding-right: 2em;
2020
}
2121

22+
input.has-two-buttons {
23+
padding-right: 4em;
24+
}
25+
26+
input.autofill-success {
27+
box-shadow: inset 0 0 0.4em #5bc0de;
28+
border-color: #5bc0de;
29+
}
30+
31+
input.autofill-fail {
32+
box-shadow: inset 0 0 0.4em var(--bs-red);
33+
border-color: var(--bs-danger-border-subtle);
34+
}
35+
2236
.lock-button {
2337
position: relative;
2438
height: 1.6rem;
@@ -29,15 +43,35 @@ input.hasLock {
2943
cursor: pointer;
3044
}
3145

46+
.autofill-button {
47+
position: relative;
48+
height: 1.6rem;
49+
top: -3.4rem;
50+
right: 1.75rem;
51+
margin-left: calc(100% - 1.5rem);
52+
padding: 0.1rem 0.4rem 0.1rem;
53+
cursor: pointer;
54+
}
55+
3256
.input-with-lock {
3357
max-height: 1rem;
3458
}
3559

36-
.fa-thumbtack {
60+
.fa-thumbtack,
61+
.fa-wand-magic-sparkles {
3762
color: #aaaaaa;
3863
transition: 0.2s;
3964
}
4065

66+
.fa-thumbtack:hover,
67+
.fa-wand-magic-sparkles:hover {
68+
color: #777777;
69+
}
70+
71+
.fa-wand-magic-sparkles.disabled {
72+
color: #cccccc;
73+
}
74+
4175
.fa-thumbtack.active {
4276
color: #333333;
4377
position: relative;
@@ -68,3 +102,25 @@ input.hasLock {
68102
.SongForm-PasteAlert {
69103
cursor: pointer;
70104
}
105+
106+
@keyframes shake {
107+
0% {
108+
transform: translateX(0);
109+
}
110+
25% {
111+
transform: translateX(4px);
112+
}
113+
50% {
114+
transform: translateX(-4px);
115+
}
116+
75% {
117+
transform: translateX(4px);
118+
}
119+
100% {
120+
transform: translateX(0);
121+
}
122+
}
123+
124+
.autofill-fail {
125+
animation: shake 0.18s 2;
126+
}

src/domains/scrobbleSong/SongForm.tsx

+51-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import addDays from 'date-fns/addDays';
1010
import { Button, ButtonGroup, Form, FormGroup, Input, Label } from 'reactstrap';
1111

1212
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
13-
import { faThumbtack, faExchangeAlt, faHeart } from '@fortawesome/free-solid-svg-icons';
13+
import { faThumbtack, faExchangeAlt, faHeart, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons';
1414
import { faLightbulb } from '@fortawesome/free-regular-svg-icons';
1515

1616
import { useSettings } from 'hooks/useSettings';
@@ -24,12 +24,21 @@ import './SongForm.css';
2424

2525
import type { Scrobble } from 'utils/types/scrobble';
2626

27+
import { trackGetInfo } from 'utils/clients/lastfm/methods/trackGetInfo';
28+
import { get } from 'lodash-es';
29+
2730
const DateTimePicker = lazyWithPreload(() => import('components/DateTimePicker'));
2831
const Tooltip = lazyWithPreload(() => import('components/Tooltip'));
2932

3033
const reAutoPasteSplitting = / - | ?[] ?/;
3134
const controlOrder = ['artist', 'title', 'album']; // Used for arrow navigation
3235

36+
enum AutoFillStatus {
37+
Idle = '',
38+
Success = 'autofill-success',
39+
Fail = 'autofill-fail',
40+
}
41+
3342
type SongMatch = {
3443
artist: string;
3544
title: string;
@@ -81,6 +90,7 @@ export function SongForm() {
8190
const [timestamp, setTimestamp] = useState(new Date());
8291
const [title, setTitle] = useState('');
8392
const [isCustomDate, setIsCustomDate] = useState(false);
93+
const [albumAutoFillStatus, setalbumAutoFillStatus] = useState(AutoFillStatus.Idle);
8494

8595
const dispatch = useDispatch();
8696
const { isLoading: settingsLoading, settings } = useSettings();
@@ -290,6 +300,22 @@ export function SongForm() {
290300
setFormValid(artist.trim().length > 0 && title.trim().length > 0);
291301
};
292302

303+
const autoFillAlbum = async () => {
304+
if (!formIsValid) {
305+
return;
306+
}
307+
const info = await trackGetInfo({ artist, title });
308+
const suggestedAlbum = get(info, 'album.title');
309+
310+
if (suggestedAlbum === undefined) {
311+
setalbumAutoFillStatus(AutoFillStatus.Fail);
312+
} else {
313+
setalbumAutoFillStatus(AutoFillStatus.Success);
314+
setAlbum(suggestedAlbum);
315+
}
316+
setTimeout(() => setalbumAutoFillStatus(AutoFillStatus.Idle), 1200);
317+
};
318+
293319
return (
294320
<Form className="SongForm" data-cy="SongForm">
295321
<FormGroup className="row">
@@ -303,7 +329,7 @@ export function SongForm() {
303329
name="artist"
304330
id="artist"
305331
tabIndex={1}
306-
className="hasLock"
332+
className="has-button"
307333
data-cy="SongForm-artist"
308334
value={artist}
309335
onChange={(e) => setArtist((e.target as HTMLInputElement).value)}
@@ -365,14 +391,15 @@ export function SongForm() {
365391
name="album"
366392
id="album"
367393
tabIndex={3}
368-
className="hasLock"
394+
className={'has-two-buttons ' + albumAutoFillStatus}
369395
data-cy="SongForm-album"
370396
value={album}
371397
onChange={(e) => setAlbum((e.target as HTMLInputElement).value)}
372398
onKeyUp={catchKeys}
373399
/>
400+
374401
<div
375-
className="lock-button rounded"
402+
className={'lock-button rounded ' + albumAutoFillStatus}
376403
id="lock-album"
377404
data-cy="SongForm-album-lock"
378405
onClick={toggleLock('album')}
@@ -382,6 +409,25 @@ export function SongForm() {
382409
<Tooltip target="lock-album">
383410
<Trans i18nKey="lockAlbum">Lock album</Trans>
384411
</Tooltip>
412+
413+
<div
414+
className={'autofill-button rounded ' + albumAutoFillStatus}
415+
id="autofill-album"
416+
data-cy="SongForm-autofill-button"
417+
onClick={autoFillAlbum}
418+
>
419+
<FontAwesomeIcon
420+
icon={faWandMagicSparkles}
421+
className={formIsValid || albumAutoFillStatus === AutoFillStatus.Fail ? '' : 'disabled'}
422+
/>
423+
</div>
424+
<Tooltip target="autofill-album">
425+
{formIsValid ? (
426+
<Trans i18nKey="autoFillAlbum">Fill album name from last.fm</Trans>
427+
) : (
428+
<Trans i18nKey="autoFillAlbumInvalidForm">Enter artist and track for album auto-fill</Trans>
429+
)}
430+
</Tooltip>
385431
</div>
386432
</FormGroup>
387433
<FormGroup className="row">
@@ -395,7 +441,7 @@ export function SongForm() {
395441
name="albumArtist"
396442
id="albumArtist"
397443
tabIndex={3}
398-
className="hasLock"
444+
className="has-button"
399445
data-cy="SongForm-albumArtist"
400446
value={albumArtist}
401447
onChange={(e) => setAlbumArtist((e.target as HTMLInputElement).value)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { lastfmAPI } from '../apiClient';
2+
import { trackGetInfo } from './trackGetInfo';
3+
4+
vi.mock('../apiClient');
5+
6+
describe('Last.fm client: `trackGetInfo` method', () => {
7+
it('calls the API to get the track info', async () => {
8+
await trackGetInfo({ mbid: '1', artist: 'test-artist', title: 'test' });
9+
10+
expect(lastfmAPI.get).toHaveBeenCalledWith('', {
11+
params: expect.objectContaining({
12+
method: 'track.getInfo',
13+
}),
14+
});
15+
});
16+
17+
it('uses the mbid of the track', async () => {
18+
await trackGetInfo({ mbid: '1', artist: 'test-artist', title: 'test' });
19+
20+
expect(lastfmAPI.get).toHaveBeenCalledWith('', {
21+
params: expect.objectContaining({
22+
mbid: '1',
23+
}),
24+
});
25+
});
26+
27+
it('uses the track and artist name in absence of an mbid', async () => {
28+
await trackGetInfo({ artist: 'test-artist', title: 'test' });
29+
30+
expect(lastfmAPI.get).toHaveBeenCalledWith('', {
31+
params: expect.objectContaining({
32+
artist: 'test-artist',
33+
track: 'test',
34+
}),
35+
});
36+
});
37+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { get } from 'lodash-es';
2+
import { lastfmAPI } from '../apiClient';
3+
4+
export async function trackGetInfo(track: { mbid?: string; artist: string; title: string }) {
5+
const searchParams = track.mbid
6+
? {
7+
mbid: track.mbid,
8+
}
9+
: {
10+
artist: track.artist,
11+
track: track.title,
12+
};
13+
14+
const response = await lastfmAPI.get('', {
15+
params: {
16+
method: 'track.getInfo',
17+
...searchParams,
18+
},
19+
});
20+
21+
return get(response, 'data.track');
22+
}

0 commit comments

Comments
 (0)