Details pages for Song, Artist, Album, Tag and back-end PUT fixes (#21)

Details pages for Song, Artist, Album, Tag and back-end PUT fixes

Reviewed-on: #21
statistics_page
Sander Vocke 5 years ago
parent 8f4157add4
commit 6b89e618ce
  1. 29
      client/src/components/MainWindow.tsx
  2. 9
      client/src/components/appbar/AppBar.tsx
  3. 93
      client/src/components/common/EditableText.tsx
  4. 28
      client/src/components/common/StoreLinkIcon.tsx
  5. 13
      client/src/components/common/SubmitChangesButton.tsx
  6. 30
      client/src/components/tables/ResultsTable.tsx
  7. 154
      client/src/components/windows/AlbumWindow.tsx
  8. 154
      client/src/components/windows/ArtistWindow.tsx
  9. 33
      client/src/components/windows/QueryWindow.tsx
  10. 126
      client/src/components/windows/SongWindow.tsx
  11. 177
      client/src/components/windows/TagWindow.tsx
  12. 19
      client/src/components/windows/Windows.tsx
  13. 57
      client/src/lib/saveChanges.tsx
  14. 28
      client/src/lib/songGetters.tsx
  15. 41
      server/endpoints/ModifyAlbumEndpointHandler.ts
  16. 30
      server/endpoints/ModifyArtistEndpointHandler.ts
  17. 52
      server/endpoints/ModifySongEndpointHandler.ts

@ -1,5 +1,5 @@
import React, { useReducer, useState, Reducer } from 'react'; import React, { useReducer, Reducer } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme, withWidth } from '@material-ui/core'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors'; import { grey } from '@material-ui/core/colors';
import AppBar from './appbar/AppBar'; import AppBar from './appbar/AppBar';
import QueryWindow from './windows/QueryWindow'; import QueryWindow from './windows/QueryWindow';
@ -48,7 +48,6 @@ export function MainWindowReducer(state: MainWindowState, action: any) {
activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab, activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab,
} }
case MainWindowStateActions.AddTab: case MainWindowStateActions.AddTab:
console.log("Add tab: ", action)
return { return {
...state, ...state,
tabStates: [...state.tabStates, action.tabState], tabStates: [...state.tabStates, action.tabState],
@ -65,17 +64,33 @@ export function MainWindowReducer(state: MainWindowState, action: any) {
}) })
} }
default: default:
throw new Error("Unimplemented QueryWindow state update.") throw new Error("Unimplemented MainWindow state update.")
} }
} }
export default function MainWindow(props: any) { export default function MainWindow(props: any) {
const [state, dispatch] = useReducer(MainWindowReducer, { const [state, dispatch] = useReducer(MainWindowReducer, {
tabStates: [ tabStates: [
newWindowState[WindowType.Query]() newWindowState[WindowType.Query](),
newWindowState[WindowType.Song](),
newWindowState[WindowType.Album](),
newWindowState[WindowType.Artist](),
newWindowState[WindowType.Tag](),
],
tabReducers: [
newWindowReducer[WindowType.Query],
newWindowReducer[WindowType.Song],
newWindowReducer[WindowType.Album],
newWindowReducer[WindowType.Artist],
newWindowReducer[WindowType.Tag],
],
tabTypes: [
WindowType.Query,
WindowType.Song,
WindowType.Album,
WindowType.Artist,
WindowType.Tag,
], ],
tabReducers: [newWindowReducer[WindowType.Query]],
tabTypes: [WindowType.Query],
activeTab: 0 activeTab: 0
}) })

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React from 'react';
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core'; import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close'; import CloseIcon from '@material-ui/icons/Close';
import AddIcon from '@material-ui/icons/Add'; import AddIcon from '@material-ui/icons/Add';
@ -61,7 +61,12 @@ export default function AppBar(props: IProps) {
<Box m={0.5} display="flex" alignItems="center"> <Box m={0.5} display="flex" alignItems="center">
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> <img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img>
</Box> </Box>
<Tabs value={props.selectedTab} onChange={(e: any, v: number) => props.setSelectedTab(v)}> <Tabs
value={props.selectedTab}
onChange={(e: any, v: number) => props.setSelectedTab(v)}
variant="scrollable"
scrollButtons="auto"
>
{props.tabLabels.map((l: string, idx: number) => <Tab {props.tabLabels.map((l: string, idx: number) => <Tab
label={l} label={l}
value={idx} value={idx}

@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Box, IconButton, TextField } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import UndoIcon from '@material-ui/icons/Undo';
import { useTheme } from '@material-ui/core/styles';
// This component is an editable text. It shows up as normal text,
// but will display an edit icon on hover. When clicked, this
// enables a text input to make a new suggestion.
// The text can show a striked-through version of the old text,
// with the new value next to it and an undo button.
export interface IProps {
defaultValue: string,
changedValue: string | null, // Null == not changed
editingValue: string | null, // Null == not editing
editingLabel: string,
onChangeEditingValue: (v: string | null) => void,
onChangeChangedValue: (v: string | null) => void,
}
export default function EditableText(props: IProps) {
let editingValue = props.editingValue;
let defaultValue = props.defaultValue;
let changedValue = props.changedValue;
let onChangeEditingValue = props.onChangeEditingValue;
let onChangeChangedValue = props.onChangeChangedValue;
let editing = editingValue !== null;
let editingLabel = props.editingLabel;
const theme = useTheme();
const [hovering, setHovering] = useState<Boolean>(false);
const editButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => onChangeEditingValue(changedValue || defaultValue)}
>
<EditIcon />
</IconButton>
</Box>
const discardChangesButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => {
onChangeChangedValue(null);
onChangeEditingValue(null);
}}
>
<UndoIcon />
</IconButton>
</Box>
if (editing) {
return <Box display="flex" alignItems="center">
<TextField
variant="outlined"
value={editingValue || ""}
label={editingLabel}
inputProps={{ style: { fontSize: '2rem' } }}
onChange={(e: any) => onChangeEditingValue(e.target.value)}
/>
<IconButton
onClick={() => {
onChangeChangedValue(editingValue === defaultValue ? null : editingValue);
onChangeEditingValue(null);
}}
><CheckIcon /></IconButton>
</Box>
} else if (changedValue) {
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>
<del style={{ color: theme.palette.text.secondary }}>{defaultValue}</del>
{changedValue}
{editButton}
{discardChangesButton}
</Box>
}
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>{defaultValue}{editButton}</Box>;
}

@ -0,0 +1,28 @@
import React from 'react';
import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg';
export enum ExternalStore {
GooglePlayMusic = "GPM",
}
export interface IProps {
whichStore: ExternalStore,
}
export function whichStore(url: string) {
if(url.includes('play.google.com')) {
return ExternalStore.GooglePlayMusic;
}
return undefined;
}
export default function StoreLinkIcon(props: any) {
const { whichStore, ...restProps } = props;
switch(whichStore) {
case ExternalStore.GooglePlayMusic:
return <GPMIcon {...restProps}/>;
default:
throw new Error("Unknown external store: " + whichStore)
}
}

@ -0,0 +1,13 @@
import React from 'react';
import { Box, Button } from '@material-ui/core';
export default function SubmitChangesButton(props: any) {
return <Box>
<Button
{...props}
variant="contained" color="secondary"
>
Save Changes
</Button>
</Box>
}

@ -7,6 +7,7 @@ import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album'; import AlbumIcon from '@material-ui/icons/Album';
import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import { songGetters } from '../../lib/songGetters';
export interface SongGetters { export interface SongGetters {
getTitle: (song: any) => string, getTitle: (song: any) => string,
@ -25,13 +26,18 @@ export interface IProps {
mainDispatch: (action: any) => void, mainDispatch: (action: any) => void,
} }
export function SongTable(props: IProps) { export default function SongTable(props: IProps) {
const useTableStyles = makeStyles({ const classes = makeStyles({
button: {
textTransform: "none",
fontWeight: 400,
paddingLeft: '0',
textAlign: 'left',
},
table: { table: {
minWidth: 650, minWidth: 650,
}, },
}); })();
const classes = useTableStyles();
return ( return (
<TableContainer component={Paper}> <TableContainer component={Paper}>
@ -66,6 +72,8 @@ export function SongTable(props: IProps) {
tabLabel: <><PersonIcon />{mainArtistName}</>, tabLabel: <><PersonIcon />{mainArtistName}</>,
artistId: mainArtistId, artistId: mainArtistId,
metadata: null, metadata: null,
songGetters: songGetters,
songsByArtist: null,
}, },
tabReducer: newWindowReducer[WindowType.Artist], tabReducer: newWindowReducer[WindowType.Artist],
tabType: WindowType.Artist, tabType: WindowType.Artist,
@ -79,6 +87,8 @@ export function SongTable(props: IProps) {
tabLabel: <><AlbumIcon />{mainAlbumName}</>, tabLabel: <><AlbumIcon />{mainAlbumName}</>,
albumId: mainAlbumId, albumId: mainAlbumId,
metadata: null, metadata: null,
songGetters: songGetters,
songsOnAlbum: null,
}, },
tabReducer: newWindowReducer[WindowType.Album], tabReducer: newWindowReducer[WindowType.Album],
tabType: WindowType.Album, tabType: WindowType.Album,
@ -105,6 +115,8 @@ export function SongTable(props: IProps) {
tabLabel: <><LocalOfferIcon />{name}</>, tabLabel: <><LocalOfferIcon />{name}</>,
tagId: id, tagId: id,
metadata: null, metadata: null,
songGetters: songGetters,
songsWithTag: null,
}, },
tabReducer: newWindowReducer[WindowType.Tag], tabReducer: newWindowReducer[WindowType.Tag],
tabType: WindowType.Tag, tabType: WindowType.Tag,
@ -118,20 +130,12 @@ export function SongTable(props: IProps) {
return <Box ml={0.5} mr={0.5}> return <Box ml={0.5} mr={0.5}>
<Chip size="small" <Chip size="small"
label={fullTag} label={fullTag}
onClick={() => onClickTag(tagIds[i][tagIds[i].length-1], fullTag)} onClick={() => onClickTag(tagIds[i][tagIds[i].length - 1], fullTag)}
/> />
</Box> </Box>
}); });
const TextCell = (props: any) => { const TextCell = (props: any) => {
const classes = makeStyles({
button: {
textTransform: "none",
fontWeight: 400,
paddingLeft: '0',
textAlign: 'left',
}
})();
return <TableCell padding="none" {...props}> return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true} onClick={props._onClick}> <Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box <Box

@ -1,26 +1,43 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import AlbumIcon from '@material-ui/icons/Album'; import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
import { saveAlbumChanges } from '../../lib/saveChanges';
var _ = require('lodash');
export interface AlbumMetadata { export type AlbumMetadata = serverApi.AlbumDetails;
name: string, export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest;
}
export interface AlbumWindowState extends WindowState { export interface AlbumWindowState extends WindowState {
albumId: number, albumId: number,
metadata: AlbumMetadata | null, metadata: AlbumMetadata | null,
pendingChanges: AlbumMetadataChanges | null,
songsOnAlbum: any[] | null,
songGetters: SongGetters,
} }
export enum AlbumWindowStateActions { export enum AlbumWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
Reload = "Reload",
} }
export function AlbumWindowReducer(state: AlbumWindowState, action: any) { export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
switch (action.type) { switch (action.type) {
case AlbumWindowStateActions.SetMetadata: case AlbumWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case AlbumWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case AlbumWindowStateActions.SetSongs:
return { ...state, songsOnAlbum: action.value }
case AlbumWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsOnAlbum: null }
default: default:
throw new Error("Unimplemented AlbumWindow state update.") throw new Error("Unimplemented AlbumWindow state update.")
} }
@ -63,25 +80,109 @@ export async function getAlbumMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let album = json.albums[0]; let album = json.albums[0];
return { return album;
name: album.name
}
})(); })();
} }
export default function AlbumWindow(props: IProps) { export default function AlbumWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the album's metadata.
useEffect(() => { useEffect(() => {
getAlbumMetadata(props.state.albumId) getAlbumMetadata(props.state.albumId)
.then((m: AlbumMetadata) => { .then((m: AlbumMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: AlbumWindowStateActions.SetMetadata, type: AlbumWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.name]); }, [metadata?.name]);
// Effect to get the album's songs.
useEffect(() => {
if (props.state.songsOnAlbum) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.albumId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.albumId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: AlbumWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsOnAlbum]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: AlbumWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
saveAlbumChanges(props.state.albumId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: AlbumWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -89,13 +190,42 @@ export default function AlbumWindow(props: IProps) {
mt={4} mt={4}
width="80%" width="80%"
> >
<AlbumIcon style={{ fontSize: 80 }}/> <AlbumIcon style={{ fontSize: 80 }} />
</Box> </Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.name}</Typography>} {metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs in this album in your library:</Typography>
</Box>
{props.state.songsOnAlbum && <SongTable
songs={props.state.songsOnAlbum}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsOnAlbum && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -1,26 +1,43 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core';
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
import { saveArtistChanges } from '../../lib/saveChanges';
var _ = require('lodash');
export interface ArtistMetadata { export type ArtistMetadata = serverApi.ArtistDetails;
name: string, export type ArtistMetadataChanges = serverApi.ModifyArtistRequest;
}
export interface ArtistWindowState extends WindowState { export interface ArtistWindowState extends WindowState {
artistId: number, artistId: number,
metadata: ArtistMetadata | null, metadata: ArtistMetadata | null,
pendingChanges: ArtistMetadataChanges | null,
songsByArtist: any[] | null,
songGetters: SongGetters,
} }
export enum ArtistWindowStateActions { export enum ArtistWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
Reload = "Reload",
} }
export function ArtistWindowReducer(state: ArtistWindowState, action: any) { export function ArtistWindowReducer(state: ArtistWindowState, action: any) {
switch (action.type) { switch (action.type) {
case ArtistWindowStateActions.SetMetadata: case ArtistWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case ArtistWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case ArtistWindowStateActions.SetSongs:
return { ...state, songsByArtist: action.value }
case ArtistWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsByArtist: null }
default: default:
throw new Error("Unimplemented ArtistWindow state update.") throw new Error("Unimplemented ArtistWindow state update.")
} }
@ -63,25 +80,109 @@ export async function getArtistMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let artist = json.artists[0]; let artist = json.artists[0];
return { return artist;
name: artist.name
}
})(); })();
} }
export default function ArtistWindow(props: IProps) { export default function ArtistWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the artist's metadata.
useEffect(() => { useEffect(() => {
getArtistMetadata(props.state.artistId) getArtistMetadata(props.state.artistId)
.then((m: ArtistMetadata) => { .then((m: ArtistMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: ArtistWindowStateActions.SetMetadata, type: ArtistWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.name]); }, [metadata?.name]);
// Effect to get the artist's songs.
useEffect(() => {
if (props.state.songsByArtist) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.artistId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.artistId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: ArtistWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsByArtist]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: ArtistWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
saveArtistChanges(props.state.artistId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: ArtistWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -89,13 +190,42 @@ export default function ArtistWindow(props: IProps) {
mt={4} mt={4}
width="80%" width="80%"
> >
<PersonIcon style={{ fontSize: 80 }}/> <PersonIcon style={{ fontSize: 80 }} />
</Box> </Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.name}</Typography>} {metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs by this artist in your library:</Typography>
</Box>
{props.state.songsByArtist && <SongTable
songs={props.state.songsByArtist}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsByArtist && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -3,8 +3,8 @@ import { createMuiTheme, Box, LinearProgress } from '@material-ui/core';
import { QueryElem, toApiQuery } from '../../lib/query/Query'; import { QueryElem, toApiQuery } from '../../lib/query/Query';
import QueryBuilder from '../querybuilder/QueryBuilder'; import QueryBuilder from '../querybuilder/QueryBuilder';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { SongTable } from '../tables/ResultsTable'; import SongTable from '../tables/ResultsTable';
import stringifyList from '../../lib/stringifyList'; import { songGetters } from '../../lib/songGetters';
import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters'; import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters';
import { grey } from '@material-ui/core/colors'; import { grey } from '@material-ui/core/colors';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
@ -72,35 +72,6 @@ export default function QueryWindow(props: IProps) {
const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query)); const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query));
const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : []; const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : [];
const songGetters = {
getTitle: (song: any) => song.title,
getId: (song: any) => song.songId,
getArtistNames: (song: any) => song.artists.map((a: any) => a.name),
getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId),
getAlbumNames: (song: any) => song.albums.map((a: any) => a.name),
getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId),
getTagNames: (song: any) => {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [tag.name];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
getTagIds: (song: any) => {
// Recursively resolve the id.
const resolveTag = (tag: any) => {
var r = [tag.tagId];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
}
const doQuery = async (_query: QueryElem) => { const doQuery = async (_query: QueryElem) => {
var q: serverApi.QueryRequest = { var q: serverApi.QueryRequest = {
query: toApiQuery(_query), query: toApiQuery(_query),

@ -1,26 +1,40 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core';
import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import { ArtistMetadata } from './ArtistWindow';
import { AlbumMetadata } from './AlbumWindow';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import { saveSongChanges } from '../../lib/saveChanges';
export interface SongMetadata { export type SongMetadata = serverApi.SongDetails;
title: string, export type SongMetadataChanges = serverApi.ModifySongRequest;
}
export interface SongWindowState extends WindowState { export interface SongWindowState extends WindowState {
songId: number, songId: number,
metadata: SongMetadata | null, metadata: SongMetadata | null,
pendingChanges: SongMetadataChanges | null,
} }
export enum SongWindowStateActions { export enum SongWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
Reload = "Reload",
} }
export function SongWindowReducer(state: SongWindowState, action: any) { export function SongWindowReducer(state: SongWindowState, action: any) {
switch (action.type) { switch (action.type) {
case SongWindowStateActions.SetMetadata: case SongWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case SongWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case SongWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null }
default: default:
throw new Error("Unimplemented SongWindow state update.") throw new Error("Unimplemented SongWindow state update.")
} }
@ -63,25 +77,82 @@ export async function getSongMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let song = json.songs[0]; let song = json.songs[0];
return { return song;
title: song.title
}
})(); })();
} }
export default function SongWindow(props: IProps) { export default function SongWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
useEffect(() => { useEffect(() => {
getSongMetadata(props.state.songId) getSongMetadata(props.state.songId)
.then((m: SongMetadata) => { .then((m: SongMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: SongWindowStateActions.SetMetadata, type: SongWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.title]); }, [metadata?.title]);
const [editingTitle, setEditingTitle] = useState<string | null>(null);
const title = <Typography variant="h4"><EditableText
defaultValue={metadata?.title || "(Unknown title)"}
changedValue={pendingChanges?.title || null}
editingValue={editingTitle}
editingLabel="Title"
onChangeEditingValue={(v: string | null) => setEditingTitle(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.title = v }
else { delete newVal.title }
props.dispatch({
type: SongWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => {
return <Typography>
{artist.name}
</Typography>
});
const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => {
return <Typography>
{album.name}
</Typography>
});
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
saveSongChanges(props.state.songId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: SongWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -89,13 +160,44 @@ export default function SongWindow(props: IProps) {
mt={4} mt={4}
width="80%" width="80%"
> >
<AudiotrackIcon style={{ fontSize: 80 }}/> <AudiotrackIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{title}
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<PersonIcon />
<Box m={0.5}>
{artists}
</Box>
</Box>
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<AlbumIcon />
<Box m={0.5}>
{albums}
</Box>
</Box>
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box> </Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.title}</Typography>} {maybeSubmitButton}
</Box> </Box>
</Box> </Box>
} }

@ -1,26 +1,48 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
import { saveTagChanges } from '../../lib/saveChanges';
var _ = require('lodash');
export interface TagMetadata { export interface FullTagMetadata extends serverApi.TagDetails {
name: string, fullName: string[],
fullId: number[],
} }
export type TagMetadata = FullTagMetadata;
export type TagMetadataChanges = serverApi.ModifyTagRequest;
export interface TagWindowState extends WindowState { export interface TagWindowState extends WindowState {
tagId: number, tagId: number,
metadata: TagMetadata | null, metadata: TagMetadata | null,
pendingChanges: TagMetadataChanges | null,
songsWithTag: any[] | null,
songGetters: SongGetters,
} }
export enum TagWindowStateActions { export enum TagWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
Reload = "Reload",
} }
export function TagWindowReducer(state: TagWindowState, action: any) { export function TagWindowReducer(state: TagWindowState, action: any) {
switch (action.type) { switch (action.type) {
case TagWindowStateActions.SetMetadata: case TagWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case TagWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case TagWindowStateActions.SetSongs:
return { ...state, songsWithTag: action.value }
case TagWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsWithTag: null }
default: default:
throw new Error("Unimplemented TagWindow state update.") throw new Error("Unimplemented TagWindow state update.")
} }
@ -63,25 +85,131 @@ export async function getTagMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let tag = json.tags[0]; let tag = json.tags[0];
return {
name: tag.name // Recursively fetch parent tags to build the full metadata.
if (tag.parentId) {
const parent = await getTagMetadata(tag.parentId);
tag.fullName = [...parent.fullName, tag.name];
tag.fullId = [...parent.fullId, tag.tagId];
} else {
tag.fullName = [tag.name];
tag.fullId = [tag.tagId];
} }
return tag;
})(); })();
} }
export default function TagWindow(props: IProps) { export default function TagWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the tag's metadata.
useEffect(() => { useEffect(() => {
getTagMetadata(props.state.tagId) getTagMetadata(props.state.tagId)
.then((m: TagMetadata) => { .then((m: TagMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: TagWindowStateActions.SetMetadata, type: TagWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.name]); }, [metadata?.name]);
// Effect to get the tag's songs.
useEffect(() => {
if (props.state.songsWithTag) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.tagId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.tagId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: TagWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsWithTag]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: TagWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const fullName = <Box display="flex" alignItems="center">
{metadata?.fullName.map((n: string, i: number) => {
if (metadata?.fullName && i == metadata?.fullName.length - 1) {
return name;
} else if (i >= (metadata?.fullName.length || 0) - 1) {
return undefined;
} else {
return <Typography variant="h4">{n}&nbsp;/&nbsp;</Typography>
}
})}
</Box>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
saveTagChanges(props.state.tagId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: TagWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -89,13 +217,42 @@ export default function TagWindow(props: IProps) {
mt={4} mt={4}
width="80%" width="80%"
> >
<LocalOfferIcon style={{ fontSize: 80 }}/> <LocalOfferIcon style={{ fontSize: 80 }} />
</Box> </Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.name}</Typography>} {metadata && <Box>
<Box m={2}>
{fullName}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs with this tag in your library:</Typography>
</Box>
{props.state.songsWithTag && <SongTable
songs={props.state.songsWithTag}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsWithTag && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -9,6 +9,7 @@ import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import { SongWindowReducer } from './SongWindow'; import { SongWindowReducer } from './SongWindow';
import { AlbumWindowReducer } from './AlbumWindow'; import { AlbumWindowReducer } from './AlbumWindow';
import { TagWindowReducer } from './TagWindow'; import { TagWindowReducer } from './TagWindow';
import { songGetters } from '../../lib/songGetters';
export enum WindowType { export enum WindowType {
Query = "Query", Query = "Query",
@ -41,30 +42,40 @@ export const newWindowState = {
}, },
[WindowType.Artist]: () => { [WindowType.Artist]: () => {
return { return {
tabLabel: <><PersonIcon/>Artist</>, tabLabel: <><PersonIcon/>Artist 1</>,
artistId: 1, artistId: 1,
metadata: null, metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsByArtist: null,
} }
}, },
[WindowType.Album]: () => { [WindowType.Album]: () => {
return { return {
tabLabel: <><AlbumIcon/>Album</>, tabLabel: <><AlbumIcon/>Album 1</>,
albumId: 1, albumId: 1,
metadata: null, metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsOnAlbum: null,
} }
}, },
[WindowType.Song]: () => { [WindowType.Song]: () => {
return { return {
tabLabel: <><AudiotrackIcon/>Song</>, tabLabel: <><AudiotrackIcon/>Song 1</>,
songId: 1, songId: 1,
metadata: null, metadata: null,
pendingChanges: null,
} }
}, },
[WindowType.Tag]: () => { [WindowType.Tag]: () => {
return { return {
tabLabel: <><LocalOfferIcon/>Tag</>, tabLabel: <><LocalOfferIcon/>Tag 1</>,
tagId: 1, tagId: 1,
metadata: null, metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsWithTag: null,
} }
}, },
} }

@ -0,0 +1,57 @@
import * as serverApi from '../api';
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save song changes: " + response.statusText);
}
}
export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save tag changes: " + response.statusText);
}
}
export async function saveArtistChanges(id: number, change: serverApi.ModifyArtistRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save artist changes: " + response.statusText);
}
}
export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbumRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText);
}
}

@ -0,0 +1,28 @@
export const songGetters = {
getTitle: (song: any) => song.title,
getId: (song: any) => song.songId,
getArtistNames: (song: any) => song.artists.map((a: any) => a.name),
getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId),
getAlbumNames: (song: any) => song.albums.map((a: any) => a.name),
getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId),
getTagNames: (song: any) => {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [tag.name];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
getTagIds: (song: any) => {
// Recursively resolve the id.
const resolveTag = (tag: any) => {
var r = [tag.tagId];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
}

@ -16,13 +16,20 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res:
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try { try {
// Start retrieving the album itself.
const albumPromise = trx.select('id')
.from('albums')
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined);
// Start retrieving artists. // Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ? const artistIdsPromise = reqObject.artistIds ?
trx.select('artistId') trx.select('artistId')
.from('artists_albums') .from('artists_albums')
.whereIn('id', reqObject.artistIds) .whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['artistId'])) : .then((as: any) => as.map((a: any) => a['artistId'])) :
(async () => { return [] })(); (async () => { return undefined })();
// Start retrieving tags. // Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ? const tagIdsPromise = reqObject.tagIds ?
@ -30,13 +37,7 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res:
.from('albums_tags') .from('albums_tags')
.whereIn('id', reqObject.tagIds) .whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) : .then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return [] })(); (async () => { return undefined })();
// Start retrieving the album itself.
const albumPromise = trx.select('id')
.from('albums')
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish. // Wait for the requests to finish.
var [album, artists, tags] = await Promise.all([albumPromise, artistIdsPromise, tagIdsPromise]);; var [album, artists, tags] = await Promise.all([albumPromise, artistIdsPromise, tagIdsPromise]);;
@ -53,30 +54,30 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res:
} }
// Modify the album. // Modify the album.
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifyAlbumPromise = trx('albums') const modifyAlbumPromise = trx('albums')
.where({ 'id': req.params.id }) .where({ 'id': req.params.id })
.update({ .update(update)
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
})
// Remove unlinked artists. // Remove unlinked artists.
// TODO: test this! // TODO: test this!
const removeUnlinkedArtists = trx('artists_albums') const removeUnlinkedArtists = artists ? trx('artists_albums')
.where({ 'albumId': req.params.id }) .where({ 'albumId': req.params.id })
.whereNotIn('artistId', reqObject.artistIds || []) .whereNotIn('artistId', reqObject.artistIds || [])
.delete(); .delete() : undefined;
// Remove unlinked tags. // Remove unlinked tags.
// TODO: test this! // TODO: test this!
const removeUnlinkedTags = trx('albums_tags') const removeUnlinkedTags = tags ? trx('albums_tags')
.where({ 'albumId': req.params.id }) .where({ 'albumId': req.params.id })
.whereNotIn('tagId', reqObject.tagIds || []) .whereNotIn('tagId', reqObject.tagIds || [])
.delete(); .delete() : undefined;
// Link new artists. // Link new artists.
// TODO: test this! // TODO: test this!
const addArtists = trx('artists_albums') const addArtists = artists ? trx('artists_albums')
.where({ 'albumId': req.params.id }) .where({ 'albumId': req.params.id })
.then((as: any) => as.map((a: any) => a['artistId'])) .then((as: any) => as.map((a: any) => a['artistId']))
.then((doneArtistIds: number[]) => { .then((doneArtistIds: number[]) => {
@ -97,11 +98,11 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res:
trx('artists_albums').insert(obj) trx('artists_albums').insert(obj)
) )
); );
}) }) : undefined;
// Link new tags. // Link new tags.
// TODO: test this! // TODO: test this!
const addTags = trx('albums_tags') const addTags = tags ? trx('albums_tags')
.where({ 'albumId': req.params.id }) .where({ 'albumId': req.params.id })
.then((ts: any) => ts.map((t: any) => t['tagId'])) .then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => { .then((doneTagIds: number[]) => {
@ -122,7 +123,7 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res:
trx('albums_tags').insert(obj) trx('albums_tags').insert(obj)
) )
); );
}) }) : undefined;
// Wait for all operations to finish. // Wait for all operations to finish.
await Promise.all([ await Promise.all([

@ -17,19 +17,19 @@ export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res
try { try {
const artistId = parseInt(req.params.id); const artistId = parseInt(req.params.id);
// Start retrieving the artist itself.
const artistPromise = trx.select('id')
.from('artists')
.where({ id: artistId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving tags. // Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ? const tagIdsPromise = reqObject.tagIds ?
trx.select('id') trx.select('id')
.from('artists_tags') .from('artists_tags')
.whereIn('id', reqObject.tagIds) .whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) : .then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return [] })(); (async () => { return undefined })();
// Start retrieving the artist itself.
const artistPromise = trx.select('id')
.from('artists')
.where({ id: artistId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish. // Wait for the requests to finish.
var [artist, tags] = await Promise.all([artistPromise, tagIdsPromise]);; var [artist, tags] = await Promise.all([artistPromise, tagIdsPromise]);;
@ -45,25 +45,25 @@ export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res
} }
// Modify the artist. // Modify the artist.
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifyArtistPromise = trx('artists') const modifyArtistPromise = trx('artists')
.where({ 'id': artistId }) .where({ 'id': artistId })
.update({ .update(update)
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
})
// Remove unlinked tags. // Remove unlinked tags.
// TODO: test this! // TODO: test this!
const removeUnlinkedTags = reqObject.tagIds ? const removeUnlinkedTags = tags ?
trx('artists_tags') trx('artists_tags')
.where({ 'artistId': artistId }) .where({ 'artistId': artistId })
.whereNotIn('tagId', reqObject.tagIds || []) .whereNotIn('tagId', reqObject.tagIds || [])
.delete() : .delete() :
(async () => undefined)(); undefined;
// Link new tags. // Link new tags.
// TODO: test this! // TODO: test this!
const addTags = trx('artists_tags') const addTags = tags ? trx('artists_tags')
.where({ 'artistId': artistId }) .where({ 'artistId': artistId })
.then((ts: any) => ts.map((t: any) => t['tagId'])) .then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => { .then((doneTagIds: number[]) => {
@ -84,7 +84,7 @@ export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res
trx('artists_tags').insert(obj) trx('artists_tags').insert(obj)
) )
); );
}); }) : undefined;
// Wait for all operations to finish. // Wait for all operations to finish.
await Promise.all([ await Promise.all([

@ -16,13 +16,19 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try { try {
// Retrieve the song to be modified itself.
const songPromise = trx.select('id')
.from('songs')
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving artists. // Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ? const artistIdsPromise = reqObject.artistIds ?
trx.select('artistId') trx.select('artistId')
.from('songs_artists') .from('songs_artists')
.whereIn('id', reqObject.artistIds) .whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['artistId'])) : .then((as: any) => as.map((a: any) => a['artistId'])) :
(async () => { return [] })(); (async () => { return undefined })();
// Start retrieving tags. // Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ? const tagIdsPromise = reqObject.tagIds ?
@ -30,7 +36,7 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
.from('songs_tags') .from('songs_tags')
.whereIn('id', reqObject.tagIds) .whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) : .then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return [] })(); (async () => { return undefined })();
// Start retrieving albums. // Start retrieving albums.
const albumIdsPromise = reqObject.albumIds ? const albumIdsPromise = reqObject.albumIds ?
@ -38,13 +44,7 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
.from('songs_albums') .from('songs_albums')
.whereIn('id', reqObject.albumIds) .whereIn('id', reqObject.albumIds)
.then((as: any) => as.map((a: any) => a['albumId'])) : .then((as: any) => as.map((a: any) => a['albumId'])) :
(async () => { return [] })(); (async () => { return undefined })();
// Start retrieving the song itself.
const songPromise = trx.select('id')
.from('songs')
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish. // Wait for the requests to finish.
var [song, artists, tags, albums] = var [song, artists, tags, albums] =
@ -63,37 +63,37 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
} }
// Modify the song. // Modify the song.
var update: any = {};
if ("title" in reqObject) { update["title"] = reqObject.title; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifySongPromise = trx('songs') const modifySongPromise = trx('songs')
.where({ 'id': req.params.id }) .where({ 'id': req.params.id })
.update({ .update(update)
title: reqObject.title,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
})
// Remove unlinked artists. // Remove unlinked artists.
// TODO: test this! // TODO: test this!
const removeUnlinkedArtists = trx('artists_songs') const removeUnlinkedArtists = artists ? trx('songs_artists')
.where({ 'songId': req.params.id }) .where({ 'songId': req.params.id })
.whereNotIn('artistId', reqObject.artistIds || []) .whereNotIn('artistId', reqObject.artistIds || [])
.delete(); .delete() : undefined;
// Remove unlinked tags. // Remove unlinked tags.
// TODO: test this! // TODO: test this!
const removeUnlinkedTags = trx('songs_tags') const removeUnlinkedTags = tags ? trx('songs_tags')
.where({ 'songId': req.params.id }) .where({ 'songId': req.params.id })
.whereNotIn('tagId', reqObject.tagIds || []) .whereNotIn('tagId', reqObject.tagIds || [])
.delete(); .delete() : undefined;
// Remove unlinked albums. // Remove unlinked albums.
// TODO: test this! // TODO: test this!
const removeUnlinkedAlbums = trx('songs_albums') const removeUnlinkedAlbums = albums ? trx('songs_albums')
.where({ 'songId': req.params.id }) .where({ 'songId': req.params.id })
.whereNotIn('albumId', reqObject.albumIds || []) .whereNotIn('albumId', reqObject.albumIds || [])
.delete(); .delete() : undefined;
// Link new artists. // Link new artists.
// TODO: test this! // TODO: test this!
const addArtists = trx('artists_songs') const addArtists = artists ? trx('songs_artists')
.where({ 'songId': req.params.id }) .where({ 'songId': req.params.id })
.then((as: any) => as.map((a: any) => a['artistId'])) .then((as: any) => as.map((a: any) => a['artistId']))
.then((doneArtistIds: number[]) => { .then((doneArtistIds: number[]) => {
@ -111,14 +111,14 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
// Link them // Link them
return Promise.all( return Promise.all(
insertObjects.map((obj: any) => insertObjects.map((obj: any) =>
trx('artists_songs').insert(obj) trx('songs_artists').insert(obj)
) )
); );
}) }) : undefined;
// Link new tags. // Link new tags.
// TODO: test this! // TODO: test this!
const addTags = trx('songs_tags') const addTags = tags ? trx('songs_tags')
.where({ 'songId': req.params.id }) .where({ 'songId': req.params.id })
.then((ts: any) => ts.map((t: any) => t['tagId'])) .then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => { .then((doneTagIds: number[]) => {
@ -139,11 +139,11 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
trx('songs_tags').insert(obj) trx('songs_tags').insert(obj)
) )
); );
}) }) : undefined;
// Link new albums. // Link new albums.
// TODO: test this! // TODO: test this!
const addAlbums = trx('songs_albums') const addAlbums = albums ? trx('songs_albums')
.where({ 'albumId': req.params.id }) .where({ 'albumId': req.params.id })
.then((as: any) => as.map((a: any) => a['albumId'])) .then((as: any) => as.map((a: any) => a['albumId']))
.then((doneAlbumIds: number[]) => { .then((doneAlbumIds: number[]) => {
@ -164,7 +164,7 @@ export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res:
trx('songs_albums').insert(obj) trx('songs_albums').insert(obj)
) )
); );
}) }) : undefined;
// Wait for all operations to finish. // Wait for all operations to finish.
await Promise.all([ await Promise.all([

Loading…
Cancel
Save