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[] = []; 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 = (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 {props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running && Batch linking in progress...} {props.status.state === BatchJobState.Finished && Batch linking finished} {props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running && Closing or refreshing this page will interrupt and abort the process. } Found: {props.status.tasksSuccess}
Failed: {props.status.tasksFailed}
Total: {props.status.numTasks}
{props.status.state === BatchJobState.Finished && }
} function ConfirmDialog(props: { open: boolean onConfirm: () => void, onClose: () => void, }) { return Are you sure? This action is non-reversible. } export default function BatchLinkDialog(props: { open: boolean, onClose: () => void, }) { let integrations = useIntegrations(); let classes = useStyles(); let [confirmDialogOpen, setConfirmDialogOpen] = useState(false); let [jobStatus, setJobStatus] = useState({ state: BatchJobState.Idle, numTasks: 0, tasksSuccess: 0, tasksFailed: 0, }); var compatibleIntegrations: Record = { [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>( $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 ? {props.children} : {props.children} } return <> Batch linking 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.
Existing links are untouched.
{$enum(IntegrationWith).getValues().map((store: IntegrationWith) => { let active = Boolean(compatibleIntegrations[store].length); return ; })}
Service Use Integration Which items
{store} {!active && No integrations configured.} {active && } setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkArtists: e.target.checked } } })} /> } label={Artists} /> setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkAlbums: e.target.checked } } })} /> } label={Albums} /> setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkTracks: e.target.checked } } })} /> } label={Tracks} />
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, ) }} /> { setJobStatus({ numTasks: 0, tasksSuccess: 0, tasksFailed: 0, state: BatchJobState.Idle, }) }} status={jobStatus} /> }