You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
439 lines
18 KiB
439 lines
18 KiB
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; |
|
import { Box, Button, Checkbox, createStyles, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, FormControlLabel, LinearProgress, List, ListItem, ListItemIcon, ListItemText, makeStyles, MenuItem, Paper, Select, Theme, Typography } from "@material-ui/core"; |
|
import StoreLinkIcon from '../../common/StoreLinkIcon'; |
|
import { $enum } from 'ts-enum-util'; |
|
import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations'; |
|
import { IntegrationWith, ImplIntegratesWith, IntegrationImpl, ResourceType, QueryResponseType, IntegrationUrls } from '../../../api/api'; |
|
import { start } from 'repl'; |
|
import { QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot } from '../../../lib/query/Query'; |
|
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries'; |
|
import asyncPool from "tiny-async-pool"; |
|
import { getTrack } from '../../../lib/backend/tracks'; |
|
import { getAlbum } from '../../../lib/backend/albums'; |
|
import { getArtist } from '../../../lib/backend/artists'; |
|
import { modifyAlbum, modifyArtist, modifyTrack } from '../../../lib/saveChanges'; |
|
|
|
const useStyles = makeStyles((theme: Theme) => |
|
createStyles({ |
|
disabled: { |
|
color: theme.palette.text.disabled, |
|
}, |
|
}) |
|
); |
|
|
|
enum BatchJobState { |
|
Idle = 0, |
|
Collecting, |
|
Running, |
|
Finished, |
|
} |
|
|
|
interface Task { |
|
itemType: ResourceType, |
|
itemId: number, |
|
integrationId: number, |
|
store: IntegrationWith, |
|
} |
|
|
|
interface BatchJobStatus { |
|
state: BatchJobState, |
|
numTasks: number, |
|
tasksSuccess: number, |
|
tasksFailed: number, |
|
} |
|
|
|
async function makeTasks( |
|
integration: IntegrationState, |
|
linkTracks: boolean, |
|
linkArtists: boolean, |
|
linkAlbums: boolean, |
|
addTaskCb: (t: Task) => void, |
|
) { |
|
let whichProp: any = { |
|
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks, |
|
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks, |
|
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks, |
|
} |
|
let whichElem: any = { |
|
[ResourceType.Track]: 'tracks', |
|
[ResourceType.Artist]: 'artists', |
|
[ResourceType.Album]: 'albums', |
|
} |
|
let maybeStore = integration.integration.providesStoreLink(); |
|
if (!maybeStore) { |
|
return; |
|
} |
|
let store = maybeStore as IntegrationWith; |
|
let doForType = async (type: ResourceType) => { |
|
let ids: number[] = ((await queryItems( |
|
[type], |
|
queryNot({ |
|
a: whichProp[type], |
|
leafOp: QueryLeafOp.Like, |
|
b: `%${IntegrationUrls[store]}%`, |
|
}), |
|
undefined, |
|
undefined, |
|
QueryResponseType.Ids |
|
)) as any)[whichElem[type]]; |
|
ids.map((id: number) => { |
|
addTaskCb({ |
|
itemType: type, |
|
itemId: id, |
|
integrationId: integration.id, |
|
store: store, |
|
}); |
|
}) |
|
} |
|
var promises: Promise<any>[] = []; |
|
if (linkTracks) { promises.push(doForType(ResourceType.Track)); } |
|
if (linkArtists) { promises.push(doForType(ResourceType.Artist)); } |
|
if (linkAlbums) { promises.push(doForType(ResourceType.Album)); } |
|
await Promise.all(promises); |
|
} |
|
|
|
async function doLinking( |
|
toLink: { integrationId: number, tracks: boolean, artists: boolean, albums: boolean }[], |
|
setStatus: any, |
|
integrations: IntegrationState[], |
|
) { |
|
// Start the collecting phase. |
|
setStatus((s: any) => { |
|
return { |
|
state: BatchJobState.Collecting, |
|
numTasks: 0, |
|
tasksSuccess: 0, |
|
tasksFailed: 0, |
|
} |
|
}); |
|
var tasks: Task[] = []; |
|
|
|
let collectionPromises = toLink.map((v: any) => { |
|
let { integrationId, tracks, artists, albums } = v; |
|
let integration = integrations.find((i: IntegrationState) => i.id === integrationId); |
|
if (!integration) { return; } |
|
return makeTasks( |
|
integration, |
|
tracks, |
|
artists, |
|
albums, |
|
(t: Task) => { tasks.push(t) } |
|
); |
|
}) |
|
await Promise.all(collectionPromises); |
|
// Start the linking phase. |
|
setStatus((status: BatchJobStatus) => { |
|
return { |
|
...status, |
|
state: BatchJobState.Running, |
|
numTasks: tasks.length |
|
} |
|
}); |
|
|
|
let makeJob: (t: Task) => Promise<void> = (t: Task) => { |
|
let integration = integrations.find((i: IntegrationState) => i.id === t.integrationId); |
|
return (async () => { |
|
let onSuccess = () => |
|
setStatus((s: BatchJobStatus) => { |
|
return { |
|
...s, |
|
tasksSuccess: s.tasksSuccess + 1, |
|
} |
|
}); |
|
let onFail = () => |
|
setStatus((s: BatchJobStatus) => { |
|
return { |
|
...s, |
|
tasksFailed: s.tasksFailed + 1, |
|
} |
|
}); |
|
try { |
|
if (integration === undefined) { return; } |
|
let _integration = integration as IntegrationState; |
|
let searchFuncs: any = { |
|
[ResourceType.Track]: (q: any, l: any) => { return _integration.integration.searchTrack(q, l) }, |
|
[ResourceType.Album]: (q: any, l: any) => { return _integration.integration.searchAlbum(q, l) }, |
|
[ResourceType.Artist]: (q: any, l: any) => { return _integration.integration.searchArtist(q, l) }, |
|
} |
|
// TODO include related items in search |
|
let getFuncs: any = { |
|
[ResourceType.Track]: getTrack, |
|
[ResourceType.Album]: getAlbum, |
|
[ResourceType.Artist]: getArtist, |
|
} |
|
let queryFuncs: any = { |
|
[ResourceType.Track]: (s: any) => `${s.name}` + |
|
`${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}` + |
|
`${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`, |
|
[ResourceType.Album]: (s: any) => `${s.name}` + |
|
`${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}`, |
|
[ResourceType.Artist]: (s: any) => `${s.name}`, |
|
} |
|
let modifyFuncs: any = { |
|
[ResourceType.Track]: modifyTrack, |
|
[ResourceType.Album]: modifyAlbum, |
|
[ResourceType.Artist]: modifyArtist, |
|
} |
|
let item = await getFuncs[t.itemType](t.itemId); |
|
let query = queryFuncs[t.itemType](item); |
|
let candidates = await searchFuncs[t.itemType]( |
|
query, |
|
1, |
|
); |
|
|
|
var success = false; |
|
if (candidates && candidates.length && candidates.length > 0 && candidates[0].url) { |
|
await modifyFuncs[t.itemType]( |
|
t.itemId, |
|
{ |
|
mbApi_typename: t.itemType, |
|
storeLinks: [...item.storeLinks, candidates[0].url], |
|
} |
|
) |
|
success = true; |
|
} |
|
|
|
if (success) { |
|
onSuccess(); |
|
} else { |
|
onFail(); |
|
} |
|
} catch (e) { |
|
// Report fail |
|
console.log("Error fetching candidates: ", e) |
|
onFail(); |
|
} |
|
})(); |
|
} |
|
|
|
await asyncPool(8, tasks, makeJob); |
|
|
|
// Finalize. |
|
setStatus((status: BatchJobStatus) => { |
|
return { |
|
...status, |
|
state: BatchJobState.Finished, |
|
} |
|
}); |
|
} |
|
|
|
function ProgressDialog(props: { |
|
open: boolean, |
|
onClose: () => void, |
|
status: BatchJobStatus, |
|
}) { |
|
let donePercent = ((props.status.tasksFailed + props.status.tasksSuccess) / (props.status.numTasks || 1)) * 100; |
|
return <Dialog |
|
open={props.open} |
|
onClose={props.onClose} |
|
> |
|
{props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running && |
|
<DialogTitle>Batch linking in progress...</DialogTitle>} |
|
{props.status.state === BatchJobState.Finished && |
|
<DialogTitle>Batch linking finished</DialogTitle>} |
|
<DialogContent> |
|
{props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running && |
|
<DialogContentText> |
|
Closing or refreshing this page will interrupt and abort the process. |
|
</DialogContentText>} |
|
|
|
<Box minWidth="200px"><LinearProgress variant="determinate" color="secondary" value={donePercent} /></Box> |
|
<Typography> |
|
Found: {props.status.tasksSuccess}<br /> |
|
Failed: {props.status.tasksFailed}<br /> |
|
Total: {props.status.numTasks}<br /> |
|
</Typography> |
|
</DialogContent> |
|
{props.status.state === BatchJobState.Finished && <DialogActions> |
|
<Button variant="contained" onClick={props.onClose}>Done</Button> |
|
</DialogActions>} |
|
</Dialog> |
|
} |
|
|
|
function ConfirmDialog(props: { |
|
open: boolean |
|
onConfirm: () => void, |
|
onClose: () => void, |
|
}) { |
|
return <Dialog |
|
open={props.open} |
|
onClose={props.onClose} |
|
> |
|
<DialogTitle>Are you sure?</DialogTitle> |
|
<DialogContent> |
|
<DialogContentText> |
|
This action is non-reversible. |
|
</DialogContentText> |
|
</DialogContent> |
|
<DialogActions> |
|
<Button onClick={props.onClose} variant="outlined">Cancel</Button> |
|
<Button onClick={() => { props.onClose(); props.onConfirm(); }} variant="contained">Confirm</Button> |
|
</DialogActions> |
|
</Dialog> |
|
} |
|
|
|
export default function BatchLinkDialog(props: { |
|
open: boolean, |
|
onClose: () => void, |
|
}) { |
|
let integrations = useIntegrations(); |
|
let classes = useStyles(); |
|
let [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(false); |
|
let [jobStatus, setJobStatus] = useState<BatchJobStatus>({ |
|
state: BatchJobState.Idle, |
|
numTasks: 0, |
|
tasksSuccess: 0, |
|
tasksFailed: 0, |
|
}); |
|
|
|
var compatibleIntegrations: Record<IntegrationWith, IntegrationState[]> = { |
|
[IntegrationWith.GooglePlayMusic]: [], |
|
[IntegrationWith.YoutubeMusic]: [], |
|
[IntegrationWith.Spotify]: [], |
|
}; |
|
$enum(IntegrationWith).getValues().forEach((store: IntegrationWith) => { |
|
compatibleIntegrations[store] = Array.isArray(integrations.state) ? |
|
integrations.state.filter((i: IntegrationState) => ImplIntegratesWith[i.properties.type] === store) |
|
: []; |
|
}) |
|
|
|
interface StoreSettings { |
|
selectedIntegration: number | undefined, // Index into compatibleIntegrations |
|
linkArtists: boolean, |
|
linkTracks: boolean, |
|
linkAlbums: boolean, |
|
} |
|
|
|
let [storeSettings, setStoreSettings] = useState<Record<IntegrationWith, StoreSettings>>( |
|
$enum(IntegrationWith).getValues().reduce((prev: any, cur: IntegrationWith) => { |
|
return { |
|
...prev, |
|
[cur]: { |
|
selectedIntegration: compatibleIntegrations[cur].length > 0 ? 0 : undefined, |
|
linkArtists: false, |
|
linkTracks: false, |
|
linkAlbums: false, |
|
} |
|
} |
|
}, {}) |
|
); |
|
|
|
let Text = (props: any) => { |
|
return props.enabled ? <Typography>{props.children}</Typography> : |
|
<Typography className={classes.disabled}>{props.children}</Typography> |
|
} |
|
|
|
return <> |
|
<Dialog |
|
maxWidth="md" |
|
fullWidth |
|
open={props.open} |
|
onClose={props.onClose} |
|
disableBackdropClick={true}> |
|
<Box m={2}> |
|
<Typography variant="h5">Batch linking</Typography> |
|
<Typography> |
|
Using this feature, links to external websites will automatically be added to existing items |
|
in your music library. This happens by using any available integrations you have configured.<br /> |
|
Existing links are untouched. |
|
</Typography> |
|
<table style={{ borderSpacing: "20px" }}> |
|
<tr> |
|
<th><Typography><b>Service</b></Typography></th> |
|
<th><Typography><b>Use Integration</b></Typography></th> |
|
<td><Typography><b>Which items</b></Typography></td> |
|
</tr> |
|
{$enum(IntegrationWith).getValues().map((store: IntegrationWith) => { |
|
let active = Boolean(compatibleIntegrations[store].length); |
|
|
|
return <tr> |
|
<td> |
|
<Box display="flex" alignItems="center" flexDirection="column"> |
|
<StoreLinkIcon whichStore={store} /> |
|
<Text enabled={active}>{store}</Text> |
|
</Box> |
|
</td> |
|
<td> |
|
{!active && <Text enabled={active}>No integrations configured.</Text>} |
|
{active && <Select fullWidth |
|
value={storeSettings[store].selectedIntegration} |
|
onChange={(e: any) => { |
|
setStoreSettings({ |
|
...storeSettings, |
|
[store]: { |
|
...storeSettings[store], |
|
selectedIntegration: e.target.value, |
|
} |
|
}) |
|
}} |
|
> |
|
{compatibleIntegrations[store].map((c: IntegrationState, idx: number) => { |
|
return <MenuItem value={idx}>{c.properties.name}</MenuItem> |
|
})} |
|
</Select>} |
|
</td> |
|
<td> |
|
<FormControlLabel control={ |
|
<Checkbox disabled={!active} checked={storeSettings[store].linkArtists} |
|
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkArtists: e.target.checked } } })} /> |
|
} label={<Text enabled={active}>Artists</Text>} /> |
|
<FormControlLabel control={ |
|
<Checkbox disabled={!active} checked={storeSettings[store].linkAlbums} |
|
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkAlbums: e.target.checked } } })} /> |
|
} label={<Text enabled={active}>Albums</Text>} /> |
|
<FormControlLabel control={ |
|
<Checkbox disabled={!active} checked={storeSettings[store].linkTracks} |
|
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkTracks: e.target.checked } } })} /> |
|
} label={<Text enabled={active}>Tracks</Text>} /> |
|
</td> |
|
</tr>; |
|
})} |
|
</table> |
|
<DialogActions> |
|
<Button variant="outlined" |
|
onClick={props.onClose}>Cancel</Button> |
|
<Button variant="contained" color="secondary" |
|
onClick={() => setConfirmDialogOpen(true)}>Start</Button> |
|
</DialogActions> |
|
</Box> |
|
</Dialog> |
|
<ConfirmDialog |
|
open={confirmDialogOpen} |
|
onClose={() => setConfirmDialogOpen(false)} |
|
onConfirm={() => { |
|
var toLink: any[] = []; |
|
Object.keys(storeSettings).forEach((store: string) => { |
|
let s = store as IntegrationWith; |
|
let active = Boolean(compatibleIntegrations[s].length); |
|
|
|
if (active && storeSettings[s].selectedIntegration !== undefined) { |
|
toLink.push({ |
|
integrationId: compatibleIntegrations[s][storeSettings[s].selectedIntegration || 0].id, |
|
tracks: storeSettings[s].linkTracks, |
|
artists: storeSettings[s].linkArtists, |
|
albums: storeSettings[s].linkAlbums, |
|
}); |
|
} |
|
}); |
|
doLinking( |
|
toLink, |
|
setJobStatus, |
|
integrations.state === "Loading" ? |
|
[] : integrations.state, |
|
) |
|
}} |
|
/> |
|
<ProgressDialog |
|
open={jobStatus.state === BatchJobState.Collecting || jobStatus.state === BatchJobState.Running || jobStatus.state === BatchJobState.Finished} |
|
onClose={() => { |
|
setJobStatus({ |
|
numTasks: 0, |
|
tasksSuccess: 0, |
|
tasksFailed: 0, |
|
state: BatchJobState.Idle, |
|
}) |
|
}} |
|
status={jobStatus} |
|
/> |
|
</> |
|
} |