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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ const defaultConfig = {
|
||||
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
|
||||
enableCoverAnimation: true,
|
||||
devShowArtistPage: true,
|
||||
devListenBrainzEnabled: true,
|
||||
}
|
||||
|
||||
let config
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './AboutDialog'
|
||||
export * from './AddToPlaylistDialog'
|
||||
export * from './SelectPlaylistInput'
|
||||
export * from './HelpDialog'
|
||||
export * from './ListenBrainzTokenDialog'
|
||||
|
||||
+14
-4
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ const PlayerEdit = (props) => (
|
||||
]}
|
||||
/>
|
||||
<BooleanInput source="reportRealPath" fullWidth />
|
||||
{config.lastFMEnabled && (
|
||||
{(config.lastFMEnabled || config.devListenBrainzEnabled) && (
|
||||
<BooleanInput source="scrobbleEnabled" fullWidth />
|
||||
)}
|
||||
<TextField source="client" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user