Listenbrainz scrobbling (#1424)

* Refactor session_keys to its own package

* Adjust play_tracker

- Don't send external NowPlaying/Scrobble for tracks with unknown artist
- Continue to the next agent on error

* Implement ListenBrainz Agent and Auth Router

* Implement frontend for ListenBrainz linking

* Update listenBrainzRequest

- Don't marshal Player to json
- Rename Track to Title

* Return ErrRetryLater on ListenBrainz server errors

* Add tests for listenBrainzAgent

* Add tests for ListenBrainz Client

* Adjust ListenBrainzTokenDialog to handle errors better

* Refactor listenbrainz.formatListen and listenBrainzRequest structs

* Refactor agent auth_routers

* Refactor session_keys to agents package

* Add test for listenBrainzResponse

* Add tests for ListenBrainz auth_router

* Update ListenBrainzTokenDialog and auth_router

* Adjust player scrobble toggle
This commit is contained in:
Steve Richter
2021-10-30 12:17:42 -04:00
committed by GitHub
parent ccc871d1f7
commit a56d5bc850
33 changed files with 1214 additions and 54 deletions
+2
View File
@@ -20,6 +20,7 @@ import {
themeReducer,
addToPlaylistDialogReducer,
expandInfoDialogReducer,
listenBrainzTokenDialogReducer,
playerReducer,
albumViewReducer,
activityReducer,
@@ -54,6 +55,7 @@ const App = () => (
theme: themeReducer,
addToPlaylistDialog: addToPlaylistDialogReducer,
expandInfoDialog: expandInfoDialogReducer,
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
activity: activityReducer,
settings: settingsReducer,
},
+10
View File
@@ -4,6 +4,8 @@ export const DUPLICATE_SONG_WARNING_OPEN = 'DUPLICATE_SONG_WARNING_OPEN'
export const DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE'
export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({
type: ADD_TO_PLAYLIST_OPEN,
@@ -34,3 +36,11 @@ export const openExtendedInfoDialog = (record) => {
export const closeExtendedInfoDialog = () => ({
type: EXTENDED_INFO_CLOSE,
})
export const openListenBrainzTokenDialog = () => ({
type: LISTENBRAINZ_TOKEN_OPEN,
})
export const closeListenBrainzTokenDialog = () => ({
type: LISTENBRAINZ_TOKEN_CLOSE,
})
+1
View File
@@ -25,6 +25,7 @@ const defaultConfig = {
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
enableCoverAnimation: true,
devShowArtistPage: true,
devListenBrainzEnabled: true,
}
let config
+138
View File
@@ -0,0 +1,138 @@
import React, { createRef, useCallback, useState } from 'react'
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
LinearProgress,
Link,
TextField,
} from '@material-ui/core'
import { useNotify, useTranslate } from 'react-admin'
import { useDispatch, useSelector } from 'react-redux'
import { closeListenBrainzTokenDialog } from '../actions'
import { httpClient } from '../dataProvider'
export const ListenBrainzTokenDialog = ({ setLinked }) => {
const dispatch = useDispatch()
const notify = useNotify()
const translate = useTranslate()
const { open } = useSelector((state) => state.listenBrainzTokenDialog)
const [token, setToken] = useState('')
const [checking, setChecking] = useState(false)
const inputRef = createRef()
const handleChange = (event) => {
setToken(event.target.value)
}
const handleLinkClick = (event) => {
inputRef.current.focus()
}
const handleSave = useCallback(
(event) => {
setChecking(true)
httpClient('/api/listenbrainz/link', {
method: 'PUT',
body: JSON.stringify({ token: token }),
})
.then((response) => {
notify('message.listenBrainzLinkSuccess', 'success', {
user: response.json.user,
})
setLinked(true)
setToken('')
})
.catch((error) => {
notify('message.listenBrainzLinkFailure', 'warning', {
error: error.body?.error || error.message,
})
setLinked(false)
})
.finally(() => {
setChecking(false)
dispatch(closeListenBrainzTokenDialog())
event.stopPropagation()
})
},
[dispatch, notify, setLinked, token]
)
const handleClickClose = (event) => {
if (!checking) {
dispatch(closeListenBrainzTokenDialog())
event.stopPropagation()
}
}
const handleKeyPress = useCallback(
(event) => {
if (event.key === 'Enter' && token !== '') {
handleSave(event)
}
},
[token, handleSave]
)
return (
<>
<Dialog
open={open}
onClose={handleClickClose}
onBackdropClick={handleClickClose}
aria-labelledby="form-dialog-listenbrainz-token"
fullWidth={true}
maxWidth="md"
>
<DialogTitle id="form-dialog-listenbrainz-token">
ListenBrainz
</DialogTitle>
<DialogContent>
<DialogContentText>
{translate('resources.user.message.listenBrainzToken')}{' '}
<Link
href="https://listenbrainz.org/profile/"
onClick={handleLinkClick}
target="_blank"
>
{translate('resources.user.message.clickHereForToken')}
</Link>
</DialogContentText>
<TextField
value={token}
onKeyPress={handleKeyPress}
onChange={handleChange}
disabled={checking}
required
autoFocus
fullWidth={true}
variant={'outlined'}
label={translate('resources.user.fields.token')}
inputRef={inputRef}
/>
{checking && <LinearProgress />}
</DialogContent>
<DialogActions>
<Button
onClick={handleClickClose}
disabled={checking}
color="primary"
>
{translate('ra.action.cancel')}
</Button>
<Button
onClick={handleSave}
disabled={checking || token === ''}
color="primary"
data-testid="listenbrainz-token-save"
>
{translate('ra.action.save')}
</Button>
</DialogActions>
</Dialog>
</>
)
}
+1
View File
@@ -2,3 +2,4 @@ export * from './AboutDialog'
export * from './AddToPlaylistDialog'
export * from './SelectPlaylistInput'
export * from './HelpDialog'
export * from './ListenBrainzTokenDialog'
+14 -4
View File
@@ -95,7 +95,8 @@
"createdAt": "Created at",
"changePassword": "Change Password?",
"currentPassword": "Current Password",
"newPassword": "New Password"
"newPassword": "New Password",
"token": "Token"
},
"helperTexts": {
"name": "Changes to your name will only be reflected on next login"
@@ -104,6 +105,10 @@
"created": "User created",
"updated": "User updated",
"deleted": "User deleted"
},
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
"clickHereForToken": "Click here to get your token"
}
},
"player": {
@@ -116,7 +121,7 @@
"userName": "Username",
"lastSeen": "Last Seen At",
"reportRealPath": "Report Real Path",
"scrobbleEnabled": "Send Scrobbles to Last.fm"
"scrobbleEnabled": "Send Scrobbles to external services"
}
},
"transcoding": {
@@ -306,7 +311,11 @@
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
"lastfmLinkFailure": "Last.fm could not be linked",
"lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled",
"lastfmUnlinkFailure": "Last.fm could not unlinked",
"lastfmUnlinkFailure": "Last.fm could not be unlinked",
"listenBrainzLinkSuccess": "ListenBrainz successfully linked and scrobbling enabled as user: %{user}",
"listenBrainzLinkFailure": "ListenBrainz could not be linked: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz unlinked and scrobbling disabled",
"listenBrainzUnlinkFailure": "ListenBrainz could not be unlinked",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
@@ -325,7 +334,8 @@
"language": "Language",
"defaultView": "Default View",
"desktop_notifications": "Desktop Notifications",
"lastfmScrobbling": "Scrobble to Last.fm"
"lastfmScrobbling": "Scrobble to Last.fm",
"listenBrainzScrobbling": "Scrobble to ListenBrainz"
}
},
"albumList": "Albums",
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react'
import { useNotify, useTranslate } from 'react-admin'
import { FormControl, FormControlLabel, Switch } from '@material-ui/core'
import { httpClient } from '../dataProvider'
import { ListenBrainzTokenDialog } from '../dialogs'
import { useDispatch } from 'react-redux'
import { openListenBrainzTokenDialog } from '../actions'
export const ListenBrainzScrobbleToggle = () => {
const dispatch = useDispatch()
const notify = useNotify()
const translate = useTranslate()
const [linked, setLinked] = useState(null)
const toggleScrobble = () => {
if (linked) {
httpClient('/api/listenbrainz/link', { method: 'DELETE' })
.then(() => {
setLinked(false)
notify('message.listenBrainzUnlinkSuccess', 'success')
})
.catch(() => notify('message.listenBrainzUnlinkFailure', 'warning'))
} else {
dispatch(openListenBrainzTokenDialog())
}
}
useEffect(() => {
httpClient('/api/listenbrainz/link')
.then((response) => {
setLinked(response.json.status === true)
})
.catch(() => {
setLinked(false)
})
}, [])
return (
<>
<FormControl>
<FormControlLabel
control={
<Switch
id={'listenbrainz'}
color="primary"
checked={linked === true}
disabled={linked === null}
onChange={toggleScrobble}
/>
}
label={
<span>
{translate('menu.personal.options.listenBrainzScrobbling')}
</span>
}
/>
</FormControl>
<ListenBrainzTokenDialog setLinked={setLinked} />
</>
)
}
+2
View File
@@ -6,6 +6,7 @@ import { SelectTheme } from './SelectTheme'
import { SelectDefaultView } from './SelectDefaultView'
import { NotificationsToggle } from './NotificationsToggle'
import { LastfmScrobbleToggle } from './LastfmScrobbleToggle'
import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle'
import config from '../config'
const useStyles = makeStyles({
@@ -25,6 +26,7 @@ const Personal = () => {
<SelectDefaultView />
<NotificationsToggle />
{config.lastFMEnabled && <LastfmScrobbleToggle />}
{config.devListenBrainzEnabled && <ListenBrainzScrobbleToggle />}
</SimpleForm>
</Card>
)
+1 -1
View File
@@ -48,7 +48,7 @@ const PlayerEdit = (props) => (
]}
/>
<BooleanInput source="reportRealPath" fullWidth />
{config.lastFMEnabled && (
{(config.lastFMEnabled || config.devListenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
<TextField source="client" />
+25
View File
@@ -5,6 +5,8 @@ import {
DUPLICATE_SONG_WARNING_CLOSE,
EXTENDED_INFO_OPEN,
EXTENDED_INFO_CLOSE,
LISTENBRAINZ_TOKEN_OPEN,
LISTENBRAINZ_TOKEN_CLOSE,
} from '../actions'
export const addToPlaylistDialogReducer = (
@@ -61,3 +63,26 @@ export const expandInfoDialogReducer = (
return previousState
}
}
export const listenBrainzTokenDialogReducer = (
previousState = {
open: false,
},
payload
) => {
const { type } = payload
switch (type) {
case LISTENBRAINZ_TOKEN_OPEN:
return {
...previousState,
open: true,
}
case LISTENBRAINZ_TOKEN_CLOSE:
return {
...previousState,
open: false,
}
default:
return previousState
}
}