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.
 
 
 
 

448 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)); }
console.log("Awaiting answer...")
await Promise.all(promises);
}
async function doLinking(
toLink: { integrationId: number, tracks: boolean, artists: boolean, albums: boolean }[],
setStatus: any,
integrations: IntegrationState[],
) {
console.log("Linking start!", toLink);
// Start the collecting phase.
setStatus((s: any) => {
return {
state: BatchJobState.Collecting,
numTasks: 0,
tasksSuccess: 0,
tasksFailed: 0,
}
});
console.log("Starting collection");
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; }
console.log('integration collect:', integration)
return makeTasks(
integration,
tracks,
artists,
albums,
(t: Task) => { tasks.push(t) }
);
})
console.log("Awaiting collection.")
await Promise.all(collectionPromises);
console.log("Done collecting.", tasks)
// 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; }
console.log('integration search:', integration)
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,
{
storeLinks: [...item.storeLinks, candidates[0].url]
}
)
success = true;
}
console.log(query, candidates);
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}
/>
</>
}