A lot of stuff working again. Added export button.

editsong
Sander Vocke 5 years ago
parent bb9a1bdfa6
commit 35cd904a63
  1. 74
      client/package-lock.json
  2. 6
      client/package.json
  3. 1
      client/src/api/api.ts
  4. 44
      client/src/api/endpoints/importexport.ts
  5. 14
      client/src/api/types/resources.ts
  6. 9
      client/src/components/MainWindow.tsx
  7. 21
      client/src/components/tables/ResultsTable.tsx
  8. 10
      client/src/components/windows/manage/ManageWindow.tsx
  9. 65
      client/src/components/windows/manage_importexport/ManageImportExport.tsx
  10. 2
      client/src/components/windows/manage_links/BatchLinkDialog.tsx
  11. 2
      client/src/components/windows/query/QueryWindow.tsx
  12. 6
      client/src/lib/trackGetters.tsx
  13. 11
      client/src/setupProxy.js
  14. 105
      scripts/gpm_retrieve/gpm_retrieve.py
  15. 2
      server/app.ts
  16. 37
      server/db/Album.ts
  17. 119
      server/db/Artist.ts
  18. 32
      server/db/ImportExport.ts
  19. 14
      server/db/Integration.ts
  20. 59
      server/db/Query.ts
  21. 29
      server/db/Tag.ts
  22. 44
      server/db/Track.ts
  23. 4
      server/db/User.ts
  24. 10
      server/endpoints/Album.ts
  25. 8
      server/endpoints/Artist.ts
  26. 45
      server/endpoints/ImportExport.ts
  27. 6
      server/endpoints/Integration.ts
  28. 2
      server/endpoints/Query.ts
  29. 9
      server/endpoints/Tag.ts
  30. 9
      server/endpoints/Track.ts
  31. 32
      server/migrations/20200828124218_init_db.ts
  32. 2
      server/package.json

@ -1837,6 +1837,14 @@
"hoist-non-react-statics": "^3.3.0" "hoist-non-react-statics": "^3.3.0"
} }
}, },
"@types/http-proxy": {
"version": "1.17.4",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz",
"integrity": "sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==",
"requires": {
"@types/node": "*"
}
},
"@types/istanbul-lib-coverage": { "@types/istanbul-lib-coverage": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@ -6910,14 +6918,55 @@
} }
}, },
"http-proxy-middleware": { "http-proxy-middleware": {
"version": "0.19.1", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.0.6.tgz",
"integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==", "integrity": "sha512-NyL6ZB6cVni7pl+/IT2W0ni5ME00xR0sN27AQZZrpKn1b+qRh+mLbBxIq9Cq1oGfmTc7BUq4HB77mxwCaxAYNg==",
"requires": { "requires": {
"http-proxy": "^1.17.0", "@types/http-proxy": "^1.17.4",
"is-glob": "^4.0.0", "http-proxy": "^1.18.1",
"lodash": "^4.17.11", "is-glob": "^4.0.1",
"micromatch": "^3.1.10" "lodash": "^4.17.20",
"micromatch": "^4.0.2"
},
"dependencies": {
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"requires": {
"fill-range": "^7.0.1"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"requires": {
"to-regex-range": "^5.0.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"micromatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
"integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
"requires": {
"braces": "^3.0.1",
"picomatch": "^2.0.5"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"requires": {
"is-number": "^7.0.0"
}
}
} }
}, },
"http-signature": { "http-signature": {
@ -14140,6 +14189,17 @@
} }
} }
}, },
"http-proxy-middleware": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz",
"integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==",
"requires": {
"http-proxy": "^1.17.0",
"is-glob": "^4.0.0",
"lodash": "^4.17.11",
"micromatch": "^3.1.10"
}
},
"is-absolute-url": { "is-absolute-url": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz",

@ -17,6 +17,7 @@
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"@types/tiny-async-pool": "^1.0.0", "@types/tiny-async-pool": "^1.0.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"http-proxy-middleware": "^1.0.6",
"jsurl": "^0.1.5", "jsurl": "^0.1.5",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"material-table": "^1.69.0", "material-table": "^1.69.0",
@ -34,7 +35,7 @@
"uuid": "^8.3.0" "uuid": "^8.3.0"
}, },
"scripts": { "scripts": {
"dev": "BROWSER=none react-scripts start", "dev": "REACT_APP_BACKEND='/api' BROWSER=none react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
@ -53,6 +54,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
}, }
"proxy": "http://localhost:5000/"
} }

@ -9,5 +9,6 @@
export * from './types/resources'; export * from './types/resources';
export * from './endpoints/auth'; export * from './endpoints/auth';
export * from './endpoints/importexport';
export * from './endpoints/resources'; export * from './endpoints/resources';
export * from './endpoints/query'; export * from './endpoints/query';

@ -0,0 +1,44 @@
// This interface describes a JSON format in which the "interesting part"
// of the entire database for a user can be imported/exported.
// Worth noting is that the IDs used in this format only exist for cross-
// referencing between objects. They do not correspond to IDs in the actual
// database.
// Upon import, they might be replaced, and upon export, they might be randomly
import { AlbumWithRefsWithId, ArtistWithRefsWithId, isAlbumWithRefs, isArtistWithRefs, isTagWithRefs, isTrackBaseWithRefs, isTrackWithRefs, TagWithRefsWithId, TrackWithRefsWithId } from "../types/resources";
// generated.
export interface DBImportExportFormat {
tracks: TrackWithRefsWithId[],
albums: AlbumWithRefsWithId[],
artists: ArtistWithRefsWithId[],
tags: TagWithRefsWithId[],
}
// Get a full export of a user's database (GET).
export const DBExportEndpoint = "/export";
export type DBExportResponse = DBImportExportFormat;
// Fully replace the user's database by an import (POST).
export const DBImportEndpoint = "/import";
export type DBImportRequest = DBImportExportFormat;
export type DBImportResponse = void;
export const checkDBImportRequest: (v: any) => boolean = (v: any) => {
return 'tracks' in v &&
'albums' in v &&
'artists' in v &&
'tags' in v &&
v.tracks.reduce((prev: boolean, cur: any) => {
return prev && isTrackWithRefs(cur);
}, true) &&
v.albums.reduce((prev: boolean, cur: any) => {
return prev && isAlbumWithRefs(cur);
}, true) &&
v.artists.reduce((prev: boolean, cur: any) => {
return prev && isArtistWithRefs(cur);
}, true) &&
v.tags.reduce((prev: boolean, cur: any) => {
return prev && isTagWithRefs(cur);
}, true);
}

@ -49,7 +49,7 @@ export function isTrackBase(q: any): q is TrackBase {
return q.mbApi_typename && q.mbApi_typename === "track"; return q.mbApi_typename && q.mbApi_typename === "track";
} }
export function isTrackBaseWithRefs(q: any): q is TrackBaseWithRefs { export function isTrackBaseWithRefs(q: any): q is TrackBaseWithRefs {
return isTrackBase(q) && "artistIds" in q && "tagIds" in q && "albumId" in q; return isTrackBase(q);
} }
export function isTrackWithRefs(q: any): q is TrackWithRefs { export function isTrackWithRefs(q: any): q is TrackWithRefs {
return isTrackBaseWithRefs(q) && "name" in q; return isTrackBaseWithRefs(q) && "name" in q;
@ -65,10 +65,12 @@ export interface ArtistBase {
export interface ArtistBaseWithRefs extends ArtistBase { export interface ArtistBaseWithRefs extends ArtistBase {
albumIds?: number[], albumIds?: number[],
tagIds?: number[], tagIds?: number[],
trackIds?: number[],
} }
export interface ArtistBaseWithDetails extends ArtistBase { export interface ArtistBaseWithDetails extends ArtistBase {
albums: AlbumWithId[], albums: AlbumWithId[],
tags: TagWithId[], tags: TagWithId[],
tracks: TrackWithId[],
} }
export interface ArtistWithDetails extends ArtistBaseWithDetails { export interface ArtistWithDetails extends ArtistBaseWithDetails {
name: string, name: string,
@ -77,6 +79,7 @@ export interface ArtistWithRefs extends ArtistBaseWithRefs {
name: string, name: string,
albumIds: number[], albumIds: number[],
tagIds: number[], tagIds: number[],
trackIds: number[],
} }
export interface Artist extends ArtistBase { export interface Artist extends ArtistBase {
name: string, name: string,
@ -94,10 +97,10 @@ export function isArtistBase(q: any): q is ArtistBase {
return q.mbApi_typename && q.mbApi_typename === "artist"; return q.mbApi_typename && q.mbApi_typename === "artist";
} }
export function isArtistBaseWithRefs(q: any): q is ArtistBaseWithRefs { export function isArtistBaseWithRefs(q: any): q is ArtistBaseWithRefs {
return isArtistBase(q) && q && "tagIds" in q && "albumIds" in q; return isArtistBase(q);
} }
export function isArtistWithRefs(q: any): q is ArtistWithRefs { export function isArtistWithRefs(q: any): q is ArtistWithRefs {
return isTrackBaseWithRefs(q) && "name" in q; return isArtistBaseWithRefs(q) && "name" in q;
} }
@ -142,7 +145,7 @@ export function isAlbumBase(q: any): q is AlbumBase {
return q.mbApi_typename && q.mbApi_typename === "album"; return q.mbApi_typename && q.mbApi_typename === "album";
} }
export function isAlbumBaseWithRefs(q: any): q is AlbumBaseWithRefs { export function isAlbumBaseWithRefs(q: any): q is AlbumBaseWithRefs {
return isAlbumBase(q) && "artistIds" in q && "trackIds" in q && "tagIds" in q; return isAlbumBase(q);
} }
export function isAlbumWithRefs(q: any): q is AlbumWithRefs { export function isAlbumWithRefs(q: any): q is AlbumWithRefs {
return isAlbumBaseWithRefs(q) && "name" in q; return isAlbumBaseWithRefs(q) && "name" in q;
@ -183,9 +186,10 @@ export function isTagBase(q: any): q is TagBase {
return q.mbApi_typename && q.mbApi_typename === "tag"; return q.mbApi_typename && q.mbApi_typename === "tag";
} }
export function isTagBaseWithRefs(q: any): q is TagBaseWithRefs { export function isTagBaseWithRefs(q: any): q is TagBaseWithRefs {
return isTagBase(q) && "parentId" in q; return isTagBase(q);
} }
export function isTagWithRefs(q: any): q is TagWithRefs { export function isTagWithRefs(q: any): q is TagWithRefs {
console.log("Check", q)
return isTagBaseWithRefs(q) && "name" in q; return isTagBaseWithRefs(q) && "name" in q;
} }

@ -17,6 +17,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { ProvideIntegrations } from '../lib/integration/useIntegrations'; import { ProvideIntegrations } from '../lib/integration/useIntegrations';
import ManageLinksWindow from './windows/manage_links/ManageLinksWindow'; import ManageLinksWindow from './windows/manage_links/ManageLinksWindow';
import ManageWindow, { ManageWhat } from './windows/manage/ManageWindow'; import ManageWindow, { ManageWhat } from './windows/manage/ManageWindow';
import TrackWindow from './windows/track/TrackWindow';
const darkTheme = createMuiTheme({ const darkTheme = createMuiTheme({
palette: { palette: {
@ -84,9 +85,9 @@ export default function MainWindow(props: any) {
<AppBar selectedTab={AppBarTab.Browse} /> <AppBar selectedTab={AppBarTab.Browse} />
<AlbumWindow /> <AlbumWindow />
</PrivateRoute> </PrivateRoute>
<PrivateRoute path="/song/:id"> <PrivateRoute path="/track/:id">
<AppBar selectedTab={AppBarTab.Browse} /> <AppBar selectedTab={AppBarTab.Browse} />
<SongWindow /> <TrackWindow />
</PrivateRoute> </PrivateRoute>
<PrivateRoute path="/manage/tags"> <PrivateRoute path="/manage/tags">
<AppBar selectedTab={AppBarTab.Manage} /> <AppBar selectedTab={AppBarTab.Manage} />
@ -96,6 +97,10 @@ export default function MainWindow(props: any) {
<AppBar selectedTab={AppBarTab.Manage} /> <AppBar selectedTab={AppBarTab.Manage} />
<ManageWindow selectedWindow={ManageWhat.Links} /> <ManageWindow selectedWindow={ManageWhat.Links} />
</PrivateRoute> </PrivateRoute>
<PrivateRoute path="/manage/importexport">
<AppBar selectedTab={AppBarTab.Manage} />
<ManageWindow selectedWindow={ManageWhat.ImportExport} />
</PrivateRoute>
<PrivateRoute exact path="/manage"> <PrivateRoute exact path="/manage">
<Redirect to={"/manage/tags"} /> <Redirect to={"/manage/tags"} />
</PrivateRoute> </PrivateRoute>

@ -4,12 +4,12 @@ import stringifyList from '../../lib/stringifyList';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
export interface TrackGetters { export interface TrackGetters {
getTitle: (track: any) => string, getName: (track: any) => string,
getId: (track: any) => number, getId: (track: any) => number,
getArtistNames: (track: any) => string[], getArtistNames: (track: any) => string[],
getArtistIds: (track: any) => number[], getArtistIds: (track: any) => number[],
getAlbumNames: (track: any) => string[], getAlbumName: (track: any) => string | undefined,
getAlbumIds: (track: any) => number[], getAlbumId: (track: any) => number | undefined,
getTagNames: (track: any) => string[][], // Each tag is represented as a series of strings. getTagNames: (track: any) => string[][], // Each tag is represented as a series of strings.
getTagIds: (track: any) => number[][], // Each tag is represented as a series of ids. getTagIds: (track: any) => number[][], // Each tag is represented as a series of ids.
} }
@ -45,14 +45,13 @@ export default function TrackTable(props: {
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.tracks.map((track: any) => { {props.tracks.map((track: any) => {
const title = props.trackGetters.getTitle(track); const name = props.trackGetters.getName(track);
// TODO: display artists and albums separately! // TODO: display artists and albums separately!
const artistNames = props.trackGetters.getArtistNames(track); const artistNames = props.trackGetters.getArtistNames(track);
const artist = stringifyList(artistNames); const artist = stringifyList(artistNames);
const mainArtistId = props.trackGetters.getArtistIds(track)[0]; const mainArtistId = props.trackGetters.getArtistIds(track)[0];
const albumNames = props.trackGetters.getAlbumNames(track); const album = props.trackGetters.getAlbumName(track);
const album = stringifyList(albumNames); const albumId = props.trackGetters.getAlbumId(track);
const mainAlbumId = props.trackGetters.getAlbumIds(track)[0];
const trackId = props.trackGetters.getId(track); const trackId = props.trackGetters.getId(track);
const tagIds = props.trackGetters.getTagIds(track); const tagIds = props.trackGetters.getTagIds(track);
@ -61,7 +60,7 @@ export default function TrackTable(props: {
} }
const onClickAlbum = () => { const onClickAlbum = () => {
history.push('/album/' + mainAlbumId); history.push('/album/' + albumId || '');
} }
const onClickTrack = () => { const onClickTrack = () => {
@ -99,10 +98,10 @@ export default function TrackTable(props: {
</TableCell>; </TableCell>;
} }
return <TableRow key={title}> return <TableRow key={name}>
<TextCell align="left" _onClick={onClickTrack}>{title}</TextCell> <TextCell align="left" _onClick={onClickTrack}>{name}</TextCell>
<TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell> <TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell>
<TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell> {album ? <TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell> : <TextCell/>}
<TableCell padding="none" align="left" width="25%"> <TableCell padding="none" align="left" width="25%">
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
{tags} {tags}

@ -7,12 +7,15 @@ import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import SaveIcon from '@material-ui/icons/Save';
import ManageLinksWindow from '../manage_links/ManageLinksWindow'; import ManageLinksWindow from '../manage_links/ManageLinksWindow';
import ManageTagsWindow from '../manage_tags/ManageTagsWindow'; import ManageTagsWindow from '../manage_tags/ManageTagsWindow';
import ManageImportExportWindow from '../manage_importexport/ManageImportExport';
export enum ManageWhat { export enum ManageWhat {
Tags = 0, Tags = 0,
Links, Links,
ImportExport,
} }
export default function ManageWindow(props: { export default function ManageWindow(props: {
@ -52,8 +55,15 @@ export default function ManageWindow(props: {
selected={props.selectedWindow === ManageWhat.Links} selected={props.selectedWindow === ManageWhat.Links}
onClick={() => history.push('/manage/links')} onClick={() => history.push('/manage/links')}
/> />
<NavButton
label="Import/Export"
icon={<SaveIcon />}
selected={props.selectedWindow === ManageWhat.ImportExport}
onClick={() => history.push('/manage/importexport')}
/>
</Box> </Box>
{props.selectedWindow === ManageWhat.Tags && <ManageTagsWindow/>} {props.selectedWindow === ManageWhat.Tags && <ManageTagsWindow/>}
{props.selectedWindow === ManageWhat.Links && <ManageLinksWindow/>} {props.selectedWindow === ManageWhat.Links && <ManageLinksWindow/>}
{props.selectedWindow === ManageWhat.ImportExport && <ManageImportExportWindow/>}
</Box > </Box >
} }

@ -0,0 +1,65 @@
import React, { useReducer, useState } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core";
import SaveIcon from '@material-ui/icons/Save';
import { Alert } from '@material-ui/lab';
import * as serverApi from '../../../api/api';
export interface ManageImportExportWindowState extends WindowState {
dummy: boolean
}
export enum ManageImportExportWindowActions {
SetDummy = "SetDummy",
}
export function ManageImportExportWindowReducer(state: ManageImportExportWindowState, action: any) {
switch (action.type) {
case ManageImportExportWindowActions.SetDummy: {
return state;
}
default:
throw new Error("Unimplemented ManageImportExportWindow state update.")
}
}
export default function ManageImportExportWindow(props: {}) {
const [state, dispatch] = useReducer(ManageImportExportWindowReducer, {
dummy: true,
});
return <ManageImportExportWindowControlled state={state} dispatch={dispatch} />
}
export function ManageImportExportWindowControlled(props: {
state: ManageImportExportWindowState,
dispatch: (action: any) => void,
}) {
return <>
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<SaveIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<Typography variant="h4">Import / Export</Typography>
<Box mt={2} />
<Typography>
An exported database contains all your artists, albums, tracks and tags.<br />
It is represented as a JSON structure.
</Typography>
<Box mt={2} />
<Alert severity="warning">Upon importing a previously exported database, your database will be completely replaced!</Alert>
<Box mt={2} />
<a href={(process.env.REACT_APP_BACKEND || "") + serverApi.DBExportEndpoint}>
<Button variant="outlined">Export</Button>
</a>
</Box>
</Box>
</>
}

@ -171,7 +171,7 @@ async function doLinking(
[ResourceType.Artist]: getArtist, [ResourceType.Artist]: getArtist,
} }
let queryFuncs: any = { let queryFuncs: any = {
[ResourceType.Track]: (s: any) => `${s.title}` + [ResourceType.Track]: (s: any) => `${s.name}` +
`${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}` + `${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}` +
`${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`, `${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`,
[ResourceType.Album]: (s: any) => `${s.name}` + [ResourceType.Album]: (s: any) => `${s.name}` +

@ -62,7 +62,7 @@ async function getTrackNames(filter: string) {
0, -1, QueryResponseType.Details 0, -1, QueryResponseType.Details
); );
return [...(new Set([...(tracks.map((s: any) => s.title))]))]; return [...(new Set([...(tracks.map((s: any) => s.name))]))];
} }
async function getTagItems(): Promise<any> { async function getTagItems(): Promise<any> {

@ -1,10 +1,10 @@
export const trackGetters = { export const trackGetters = {
getTitle: (track: any) => track.title, getName: (track: any) => track.name,
getId: (track: any) => track.trackId, getId: (track: any) => track.trackId,
getArtistNames: (track: any) => track.artists.map((a: any) => a.name), getArtistNames: (track: any) => track.artists.map((a: any) => a.name),
getArtistIds: (track: any) => track.artists.map((a: any) => a.artistId), getArtistIds: (track: any) => track.artists.map((a: any) => a.artistId),
getAlbumNames: (track: any) => track.albums.map((a: any) => a.name), getAlbumName: (track: any) => track.album ? track.album.name : undefined,
getAlbumIds: (track: any) => track.albums.map((a: any) => a.albumId), getAlbumId: (track: any) => track.album ? track.album.albumId : undefined,
getTagNames: (track: any) => { getTagNames: (track: any) => {
// Recursively resolve the name. // Recursively resolve the name.
const resolveTag = (tag: any) => { const resolveTag = (tag: any) => {

@ -0,0 +1,11 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
process.env.REACT_APP_BACKEND,
createProxyMiddleware({
target: 'http://localhost:5000',
changeOrigin: true,
})
);
};

@ -13,7 +13,7 @@ creds_path = sys.path[0] + '/mobileclient.cred'
def authenticate(api): def authenticate(api):
creds = api.perform_oauth(storage_filepath=creds_path, open_browser=False) creds = api.perform_oauth(storage_filepath=creds_path, open_browser=False)
def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs): def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, tracks):
# First, attempt to login and start a session. # First, attempt to login and start a session.
s = requests.Session() s = requests.Session()
response = s.post(mudbase_api + '/login?username=' response = s.post(mudbase_api + '/login?username='
@ -25,26 +25,30 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs):
print("Unable to log in to MuDBase API.") print("Unable to log in to MuDBase API.")
# Helpers # Helpers
def getArtistStoreIds(song): def getArtistStoreIds(track):
if 'artistId' in song: if 'artistId' in track:
return [song['artistId'][0]] return [track['artistId'][0]]
return [] return []
def getSongStoreIds(song): def getTrackStoreIds(track):
if 'storeId' in song: if 'storeId' in track:
return [song['storeId']] return [track['storeId']]
return [] return []
# Create GPM import tag # Create GPM import tag
gpmTagIdResponse = s.post(mudbase_api + '/tag', data={ gpmTagIdResponse = s.post(mudbase_api + '/tag', json={
'name': 'GPM Import' 'name': 'GPM Import',
'mbApi_typename': 'tag',
'parentId': None,
}).json() }).json()
gpmTagId = gpmTagIdResponse['id'] gpmTagId = gpmTagIdResponse['id']
print(f"Created tag \"GPM Import\", response: {gpmTagIdResponse}") print(f"Created tag \"GPM Import\", response: {gpmTagIdResponse}")
# Create the root genre tag # Create the root genre tag
genreRootResponse = s.post(mudbase_api + '/tag', data={ genreRootResponse = s.post(mudbase_api + '/tag', json={
'name': 'Genre' 'name': 'Genre',
'mbApi_typename': 'tag',
'parentId': None,
}).json() }).json()
genreRootTagId = genreRootResponse['id'] genreRootTagId = genreRootResponse['id']
print(f"Created tag \"Genre\", response: {genreRootResponse}") print(f"Created tag \"Genre\", response: {genreRootResponse}")
@ -54,27 +58,16 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs):
storedAlbums = dict() storedAlbums = dict()
storedGenreTags = dict() storedGenreTags = dict()
for song in songs: for track in tracks:
# TODO: check if these items already exist # TODO: check if these items already exist
# Determine artist properties. # Determine artist properties.
artist = { artist = {
'name': song['artist'], 'mbApi_typename': 'artist',
'storeLinks': ['https://play.google.com/music/m' + id for id in getArtistStoreIds(song)], 'name': track['artist'],
'storeLinks': ['https://play.google.com/music/m' + id for id in getArtistStoreIds(track)],
'tagIds': [gpmTagId] 'tagIds': [gpmTagId]
} if 'artist' in song else None } if 'artist' in track else None
# Determine album properties.
album = {
'name': song['album'],
'tagIds': [gpmTagId]
} if 'album' in song else None
# Determine genre properties.
genre = {
'name': song['genre'],
'parentId': genreRootTagId
} if 'genre' in song else None
# Upload artist if not already done # Upload artist if not already done
artistId = None artistId = None
@ -90,6 +83,21 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs):
f"Created artist \"{artist['name']}\", response: {response}") f"Created artist \"{artist['name']}\", response: {response}")
storedArtists[artistId] = artist storedArtists[artistId] = artist
# Determine album properties.
album = {
'mbApi_typename': 'album',
'name': track['album'],
'tagIds': [gpmTagId],
'artistIds': [artistId],
} if 'album' in track else None
# Determine genre properties.
genre = {
'mbApi_typename': 'tag',
'name': track['genre'],
'parentId': genreRootTagId
} if 'genre' in track else None
# Upload album if not already done # Upload album if not already done
albumId = None albumId = None
if album: if album:
@ -118,44 +126,45 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs):
f"Created genre tag \"Genre / {genre['name']}\", response: {response}") f"Created genre tag \"Genre / {genre['name']}\", response: {response}")
storedGenreTags[genreTagId] = genre storedGenreTags[genreTagId] = genre
# Upload the song itself # Upload the track itself
tagIds = [gpmTagId] tagIds = [gpmTagId]
if genreTagId: if genreTagId:
tagIds.append(genreTagId) tagIds.append(genreTagId)
_song = { _track = {
'title': song['title'], 'mbApi_typename': 'track',
'name': track['title'],
'artistIds': [artistId] if artistId != None else [], 'artistIds': [artistId] if artistId != None else [],
'albumIds': [albumId] if albumId != None else [], 'albumId': albumId if albumId else None,
'tagIds': tagIds, 'tagIds': tagIds,
'storeLinks': ['https://play.google.com/music/m/' + id for id in getSongStoreIds(song)], 'storeLinks': ['https://play.google.com/music/m/' + id for id in getTrackStoreIds(track)],
} }
response = s.post(mudbase_api + '/song', json=_song).json() response = s.post(mudbase_api + '/track', json=_track).json()
print( print(
f"Created song \"{song['title']}\" with artist ID {artistId}, album ID {albumId}, response: {response}") f"Created track \"{track['title']}\" with artist ID {artistId}, album ID {albumId}, response: {response}")
def getData(api): def getData(api):
return { return {
"songs": api.get_all_songs(), "tracks": api.get_all_tracks(),
"playlists": api.get_all_user_playlist_contents() "playlists": api.get_all_user_playlist_contents()
} }
def getSongs(data): def getTracks(data):
# Get songs from library # Get tracks from library
songs = [] # data['songs'] tracks = [] # data['tracks']
# Append songs from playlists # Append tracks from playlists
for playlist in data['playlists']: for playlist in data['playlists']:
for track in playlist['tracks']: for track in playlist['tracks']:
if 'track' in track: if 'track' in track:
songs.append(track['track']) tracks.append(track['track'])
# Uniquify by using a dict. After all, same song may appear in # Uniquify by using a dict. After all, same track may appear in
# multiple playlists. # multiple playlists.
def sI(song): return song['artist'] + '-' + \ def sI(track): return track['artist'] + '-' + \
song['title'] if 'artist' in song and 'title' in song else 'z' track['title'] if 'artist' in track and 'title' in track else 'z'
return list(dict((sI(song), song) for song in songs).values()) return list(dict((sI(track), track) for track in tracks).values())
api = Mobileclient() api = Mobileclient()
@ -182,7 +191,7 @@ if args.authenticate:
data = None data = None
# Determine whether we need to log in to GPM and get songs # Determine whether we need to log in to GPM and get tracks
if args.store_to or (not args.load_from and args.mudbase_api): if args.store_to or (not args.load_from and args.mudbase_api):
api.oauth_login(Mobileclient.FROM_MAC_ADDRESS, api.oauth_login(Mobileclient.FROM_MAC_ADDRESS,
oauth_credentials=creds_path) oauth_credentials=creds_path)
@ -198,11 +207,11 @@ if args.load_from:
with open(args.load_from, 'r') as f: with open(args.load_from, 'r') as f:
data = json.load(f) data = json.load(f)
songs = getSongs(data) tracks = getTracks(data)
print(f"Found {len(songs)} songs.") print(f"Found {len(tracks)} tracks.")
if args.mudbase_api: if args.mudbase_api:
api.oauth_login(Mobileclient.FROM_MAC_ADDRESS, api.oauth_login(Mobileclient.FROM_MAC_ADDRESS,
oauth_credentials=creds_path) oauth_credentials=creds_path)
uploadLibrary(args.mudbase_api, args.mudbase_user, uploadLibrary(args.mudbase_api, args.mudbase_user,
args.mudbase_password, songs) args.mudbase_password, tracks)

@ -2,6 +2,7 @@ const bodyParser = require('body-parser');
import * as api from '../client/src/api/api'; import * as api from '../client/src/api/api';
import Knex from 'knex'; import Knex from 'knex';
import { importExportEndpoints } from './endpoints/ImportExport';
import { queryEndpoints } from './endpoints/Query'; import { queryEndpoints } from './endpoints/Query';
import { artistEndpoints } from './endpoints/Artist'; import { artistEndpoints } from './endpoints/Artist';
import { albumEndpoints } from './endpoints/Album'; import { albumEndpoints } from './endpoints/Album';
@ -120,6 +121,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
integrationEndpoints, integrationEndpoints,
userEndpoints, userEndpoints,
queryEndpoints, queryEndpoints,
importExportEndpoints,
].forEach((endpoints: [string, string, boolean, endpointTypes.EndpointHandler][]) => { ].forEach((endpoints: [string, string, boolean, endpointTypes.EndpointHandler][]) => {
endpoints.forEach((endpoint: [string, string, boolean, endpointTypes.EndpointHandler]) => { endpoints.forEach((endpoint: [string, string, boolean, endpointTypes.EndpointHandler]) => {
let [url, method, authenticated, handler] = endpoint; let [url, method, authenticated, handler] = endpoint;

@ -3,6 +3,7 @@ import { AlbumBaseWithRefs, AlbumWithDetails, AlbumWithRefs } from "../../client
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
var _ = require('lodash');
// Returns an album with details, or null if not found. // Returns an album with details, or null if not found.
export async function getAlbum(id: number, userId: number, knex: Knex): export async function getAlbum(id: number, userId: number, knex: Knex):
@ -75,13 +76,12 @@ export async function getAlbum(id: number, userId: number, knex: Knex):
// Returns the id of the created album. // Returns the id of the created album.
export async function createAlbum(userId: number, album: AlbumWithRefs, knex: Knex): Promise<number> { export async function createAlbum(userId: number, album: AlbumWithRefs, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
try {
// Start retrieving artists. // Start retrieving artists.
const artistIdsPromise: Promise<number[]> = const artistIdsPromise: Promise<number[]> =
trx.select('id') trx.select('id')
.from('artists') .from('artists')
.where({ 'user': userId }) .where({ 'user': userId })
.whereIn('id', album.artistIds) .whereIn('id', album.artistIds || [])
.then((as: any) => as.map((a: any) => a['id'])); .then((as: any) => as.map((a: any) => a['id']));
// Start retrieving tags. // Start retrieving tags.
@ -89,7 +89,7 @@ export async function createAlbum(userId: number, album: AlbumWithRefs, knex: Kn
trx.select('id') trx.select('id')
.from('tags') .from('tags')
.where({ 'user': userId }) .where({ 'user': userId })
.whereIn('id', album.tagIds) .whereIn('id', album.tagIds || [])
.then((as: any) => as.map((a: any) => a['id'])); .then((as: any) => as.map((a: any) => a['id']));
// Start retrieving tracks. // Start retrieving tracks.
@ -97,16 +97,16 @@ export async function createAlbum(userId: number, album: AlbumWithRefs, knex: Kn
trx.select('id') trx.select('id')
.from('tracks') .from('tracks')
.where({ 'user': userId }) .where({ 'user': userId })
.whereIn('id', album.trackIds) .whereIn('id', album.trackIds || [])
.then((as: any) => as.map((a: any) => a['id'])); .then((as: any) => as.map((a: any) => a['id']));
// Wait for the requests to finish. // Wait for the requests to finish.
var [artists, tags, tracks] = await Promise.all([artistIdsPromise, tagIdsPromise, trackIdsPromise]);; var [artists, tags, tracks] = await Promise.all([artistIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all artists and tags we need. // Check that we found all artists and tags we need.
if ((new Set(artists.map((a: any) => a['id'])) !== new Set(album.artistIds)) || if ((!_.isEqual(artists.sort(), (album.artistIds || []).sort())) ||
(new Set(tags.map((a: any) => a['id'])) !== new Set(album.tagIds)) || (!_.isEqual(tags.sort(), (album.tagIds || []).sort())) ||
(new Set(tracks.map((a: any) => a['id'])) !== new Set(album.trackIds))) { (!_.isEqual(tracks.sort(), (album.trackIds || []).sort()))) {
const e: DBError = { const e: DBError = {
name: "DBError", name: "DBError",
kind: DBErrorKind.ResourceNotFound, kind: DBErrorKind.ResourceNotFound,
@ -162,17 +162,11 @@ export async function createAlbum(userId: number, album: AlbumWithRefs, knex: Kn
} }
return albumId; return albumId;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function modifyAlbum(userId: number, albumId: number, album: AlbumBaseWithRefs, knex: Knex): Promise<void> { export async function modifyAlbum(userId: number, albumId: number, album: AlbumBaseWithRefs, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the album itself. // Start retrieving the album itself.
const albumIdPromise: Promise<number | undefined> = const albumIdPromise: Promise<number | undefined> =
trx.select('id') trx.select('id')
@ -212,9 +206,9 @@ export async function modifyAlbum(userId: number, albumId: number, album: AlbumB
var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);; var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all objects we need. // Check that we found all objects we need.
if ((!artists || new Set(artists.map((a: any) => a['id'])) !== new Set(album.artistIds)) || if ((!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort())) ||
(!tags || new Set(tags.map((a: any) => a['id'])) !== new Set(album.tagIds)) || (!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort())) ||
(!tracks || new Set(tracks.map((a: any) => a['id'])) !== new Set(album.trackIds)) || (!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort())) ||
!oldAlbum) { !oldAlbum) {
const e: DBError = { const e: DBError = {
name: "DBError", name: "DBError",
@ -336,11 +330,6 @@ export async function modifyAlbum(userId: number, albumId: number, album: AlbumB
]); ]);
return; return;
} catch (e) {
trx.rollback();
throw e;
}
}) })
const e: DBError = { const e: DBError = {
@ -353,7 +342,7 @@ export async function modifyAlbum(userId: number, albumId: number, album: AlbumB
export async function deleteAlbum(userId: number, albumId: number, knex: Knex): Promise<void> { export async function deleteAlbum(userId: number, albumId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start by retrieving the album itself for sanity. // Start by retrieving the album itself for sanity.
const confirmAlbumId: number | undefined = const confirmAlbumId: number | undefined =
await trx.select('id') await trx.select('id')
@ -397,9 +386,5 @@ export async function deleteAlbum(userId: number, albumId: number, knex: Knex):
// Wait for the requests to finish. // Wait for the requests to finish.
await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTracksPromise, deleteAlbumPromise]); await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTracksPromise, deleteAlbumPromise]);
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }

@ -3,6 +3,7 @@ import { ArtistBaseWithRefs, ArtistWithDetails, ArtistWithRefs } from "../../cli
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
var _ = require('lodash')
// Returns an artist with details, or null if not found. // Returns an artist with details, or null if not found.
export async function getArtist(id: number, userId: number, knex: Knex): export async function getArtist(id: number, userId: number, knex: Knex):
@ -27,7 +28,18 @@ export async function getArtist(id: number, userId: number, knex: Knex):
.then((albums: any) => albums.map((album: any) => album['albumId'])) .then((albums: any) => albums.map((album: any) => album['albumId']))
.then((ids: number[]) => .then((ids: number[]) =>
knex.select(['id', 'name', 'storeLinks']) knex.select(['id', 'name', 'storeLinks'])
.from('album') .from('albums')
.whereIn('id', ids)
);
const tracksPromise: Promise<api.TrackWithId[]> =
knex.select('trackId')
.from('tracks_artists')
.where({ 'artistId': id })
.then((tracks: any) => tracks.map((track: any) => track['trackId']))
.then((ids: number[]) =>
knex.select(['id', 'name', 'storeLinks'])
.from('tracks')
.whereIn('id', ids) .whereIn('id', ids)
); );
@ -39,8 +51,8 @@ export async function getArtist(id: number, userId: number, knex: Knex):
.then((artists: any) => artists[0]); .then((artists: any) => artists[0]);
// Wait for the requests to finish. // Wait for the requests to finish.
const [artist, tags, albums] = const [artist, tags, albums, tracks] =
await Promise.all([artistPromise, tagsPromise, albumsPromise]); await Promise.all([artistPromise, tagsPromise, albumsPromise, tracksPromise]);
if (artist) { if (artist) {
return { return {
@ -48,6 +60,7 @@ export async function getArtist(id: number, userId: number, knex: Knex):
name: artist['name'], name: artist['name'],
albums: albums as api.AlbumWithId[], albums: albums as api.AlbumWithId[],
tags: tags as api.TagWithId[], tags: tags as api.TagWithId[],
tracks: tracks as api.TrackWithId[],
storeLinks: asJson(artist['storeLinks'] || []), storeLinks: asJson(artist['storeLinks'] || []),
}; };
} }
@ -63,13 +76,20 @@ export async function getArtist(id: number, userId: number, knex: Knex):
// Returns the id of the created artist. // Returns the id of the created artist.
export async function createArtist(userId: number, artist: ArtistWithRefs, knex: Knex): Promise<number> { export async function createArtist(userId: number, artist: ArtistWithRefs, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
try {
// Start retrieving albums. // Start retrieving albums.
const albumIdsPromise: Promise<number[]> = const albumIdsPromise: Promise<number[]> =
trx.select('id') trx.select('id')
.from('albums') .from('albums')
.where({ 'user': userId }) .where({ 'user': userId })
.whereIn('id', artist.albumIds) .whereIn('id', artist.albumIds || [])
.then((as: any) => as.map((a: any) => a['id']));
// Start retrieving tracks.
const trackIdsPromise: Promise<number[]> =
trx.select('id')
.from('tracks')
.where({ 'user': userId })
.whereIn('id', artist.trackIds || [])
.then((as: any) => as.map((a: any) => a['id'])); .then((as: any) => as.map((a: any) => a['id']));
// Start retrieving tags. // Start retrieving tags.
@ -77,15 +97,16 @@ export async function createArtist(userId: number, artist: ArtistWithRefs, knex:
trx.select('id') trx.select('id')
.from('tags') .from('tags')
.where({ 'user': userId }) .where({ 'user': userId })
.whereIn('id', artist.tagIds) .whereIn('id', artist.tagIds || [])
.then((as: any) => as.map((a: any) => a['id'])); .then((as: any) => as.map((a: any) => a['id']));
// Wait for the requests to finish. // Wait for the requests to finish.
var [albums, tags] = await Promise.all([albumIdsPromise, tagIdsPromise]);; var [albums, tags, tracks] = await Promise.all([albumIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all artists and tags we need. // Check that we found all artists and tags we need.
if ((new Set(albums.map((a: any) => a['id'])) !== new Set(artist.albumIds)) || if (!_.isEqual(albums.sort(), (artist.albumIds || []).sort()) ||
(new Set(tags.map((a: any) => a['id'])) !== new Set(artist.tagIds))) { !_.isEqual(tags.sort(), (artist.tagIds || []).sort()) ||
!_.isEqual(tracks.sort(), (artist.trackIds || []).sort())) {
const e: DBError = { const e: DBError = {
name: "DBError", name: "DBError",
kind: DBErrorKind.ResourceNotFound, kind: DBErrorKind.ResourceNotFound,
@ -116,6 +137,18 @@ export async function createArtist(userId: number, artist: ArtistWithRefs, knex:
) )
} }
// Link the tracks via the linking table.
if (tracks && tracks.length) {
await trx('tracks_artists').insert(
tracks.map((trackId: number) => {
return {
trackId: trackId,
artistId: artistId,
}
})
)
}
// Link the tags via the linking table. // Link the tags via the linking table.
if (tags && tags.length) { if (tags && tags.length) {
await trx('artists_tags').insert( await trx('artists_tags').insert(
@ -129,17 +162,11 @@ export async function createArtist(userId: number, artist: ArtistWithRefs, knex:
} }
return artistId; return artistId;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function modifyArtist(userId: number, artistId: number, artist: ArtistBaseWithRefs, knex: Knex): Promise<void> { export async function modifyArtist(userId: number, artistId: number, artist: ArtistBaseWithRefs, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the artist itself. // Start retrieving the artist itself.
const artistIdPromise: Promise<number | undefined> = const artistIdPromise: Promise<number | undefined> =
trx.select('id') trx.select('id')
@ -157,6 +184,15 @@ export async function modifyArtist(userId: number, artistId: number, artist: Art
.then((as: any) => as.map((a: any) => a['albumId'])) .then((as: any) => as.map((a: any) => a['albumId']))
: (async () => undefined)(); : (async () => undefined)();
// Start retrieving tracks if we are modifying those.
const trackIdsPromise: Promise<number[] | undefined> =
artist.trackIds ?
trx.select('trackId')
.from('tracks_artists')
.whereIn('id', artist.trackIds)
.then((as: any) => as.map((a: any) => a['trackId']))
: (async () => undefined)();
// Start retrieving tags if we are modifying those. // Start retrieving tags if we are modifying those.
const tagIdsPromise = const tagIdsPromise =
artist.tagIds ? artist.tagIds ?
@ -167,11 +203,12 @@ export async function modifyArtist(userId: number, artistId: number, artist: Art
(async () => undefined)(); (async () => undefined)();
// Wait for the requests to finish. // Wait for the requests to finish.
var [oldArtist, albums, tags] = await Promise.all([artistIdPromise, albumIdsPromise, tagIdsPromise]);; var [oldArtist, albums, tags, tracks] = await Promise.all([artistIdPromise, albumIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all objects we need. // Check that we found all objects we need.
if ((!albums || new Set(albums.map((a: any) => a['id'])) !== new Set(artist.albumIds)) || if ((!albums || !_.isEqual(albums.sort(), (artist.albumIds || []).sort())) ||
(!tags || new Set(tags.map((a: any) => a['id'])) !== new Set(artist.tagIds)) || (!tags || !_.isEqual(tags.sort(), (artist.tagIds || []).sort())) ||
(!tracks || !_.isEqual(tracks.sort(), (artist.trackIds || []).sort())) ||
!oldArtist) { !oldArtist) {
const e: DBError = { const e: DBError = {
name: "DBError", name: "DBError",
@ -197,6 +234,12 @@ export async function modifyArtist(userId: number, artistId: number, artist: Art
.whereNotIn('albumId', artist.albumIds || []) .whereNotIn('albumId', artist.albumIds || [])
.delete() : undefined; .delete() : undefined;
// Remove unlinked tracks.
const removeUnlinkedTracks = tracks ? trx('tracks_artists')
.where({ 'artistId': artistId })
.whereNotIn('trackId', artist.trackIds || [])
.delete() : undefined;
// Remove unlinked tags. // Remove unlinked tags.
const removeUnlinkedTags = tags ? trx('artists_tags') const removeUnlinkedTags = tags ? trx('artists_tags')
.where({ 'artistId': artistId }) .where({ 'artistId': artistId })
@ -222,7 +265,31 @@ export async function modifyArtist(userId: number, artistId: number, artist: Art
// Link them // Link them
return Promise.all( return Promise.all(
insertObjects.map((obj: any) => insertObjects.map((obj: any) =>
trx('artists_artists').insert(obj) trx('artists_albums').insert(obj)
)
);
}) : undefined;
// Link new tracks.
const addTracks = tracks ? trx('tracks_artists')
.where({ 'artistId': artistId })
.then((as: any) => as.map((a: any) => a['trackId']))
.then((doneTrackIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = (tracks || []).filter((id: number) => {
return !doneTrackIds.includes(id);
});
const insertObjects = toLink.map((trackId: number) => {
return {
artistId: artistId,
trackId: trackId,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('tracks_artists').insert(obj)
) )
); );
}) : undefined; }) : undefined;
@ -256,22 +323,18 @@ export async function modifyArtist(userId: number, artistId: number, artist: Art
modifyArtistPromise, modifyArtistPromise,
removeUnlinkedAlbums, removeUnlinkedAlbums,
removeUnlinkedTags, removeUnlinkedTags,
removeUnlinkedTracks,
addAlbums, addAlbums,
addTags addTags,
addTracks,
]); ]);
return; return;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function deleteArtist(userId: number, artistId: number, knex: Knex): Promise<void> { export async function deleteArtist(userId: number, artistId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start by retrieving the artist itself for sanity. // Start by retrieving the artist itself for sanity.
const confirmArtistId: number | undefined = const confirmArtistId: number | undefined =
await trx.select('id') await trx.select('id')
@ -315,9 +378,5 @@ export async function deleteArtist(userId: number, artistId: number, knex: Knex)
// Wait for the requests to finish. // Wait for the requests to finish.
await Promise.all([deleteAlbumsPromise, deleteTagsPromise, deleteTracksPromise, deleteArtistPromise]); await Promise.all([deleteAlbumsPromise, deleteTagsPromise, deleteTracksPromise, deleteArtistPromise]);
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }

@ -1,5 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { TrackWithRefsWithId, AlbumWithRefsWithId, ArtistWithRefsWithId, TagWithRefsWithId, TrackWithRefs } from "../../client/src/api/api"; import { TrackWithRefsWithId, AlbumWithRefsWithId, ArtistWithRefsWithId, TagWithRefsWithId, TrackWithRefs, AlbumBaseWithRefs } from "../../client/src/api/api";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { createArtist } from "./Artist"; import { createArtist } from "./Artist";
@ -7,26 +7,12 @@ import { createTag } from "./Tag";
import { createAlbum } from "./Album"; import { createAlbum } from "./Album";
import { createTrack } from "./Track"; import { createTrack } from "./Track";
// This interface describes a JSON format in which the "interesting part" export async function exportDB(userId: number, knex: Knex): Promise<api.DBImportExportFormat> {
// of the entire database for a user can be imported/exported.
// Worth noting is that the IDs used in this format only exist for cross-
// referencing between objects. They do not correspond to IDs in the actual
// database.
// Upon import, they might be replaced, and upon export, they might be randomly
// generated.
interface DBImportExportFormat {
tracks: TrackWithRefsWithId[],
albums: AlbumWithRefsWithId[],
artists: ArtistWithRefsWithId[],
tags: TagWithRefsWithId[],
}
export async function exportDB(userId: number, knex: Knex): Promise<DBImportExportFormat> {
// First, retrieve all the objects without taking linking tables into account. // First, retrieve all the objects without taking linking tables into account.
// Fetch the links separately. // Fetch the links separately.
let tracksPromise: Promise<api.TrackWithRefsWithId[]> = let tracksPromise: Promise<api.TrackWithRefsWithId[]> =
knex.select('id', 'name', 'storeLinks', 'albumId') knex.select('id', 'name', 'storeLinks', 'album')
.from('tracks') .from('tracks')
.where({ 'user': userId }) .where({ 'user': userId })
.then((ts: any[]) => ts.map((t: any) => { .then((ts: any[]) => ts.map((t: any) => {
@ -35,7 +21,7 @@ export async function exportDB(userId: number, knex: Knex): Promise<DBImportExpo
name: t.name, name: t.name,
id: t.id, id: t.id,
storeLinks: asJson(t.storeLinks), storeLinks: asJson(t.storeLinks),
albumId: t.albumId, albumId: t.album,
artistIds: [], artistIds: [],
tagIds: [], tagIds: [],
} }
@ -137,6 +123,10 @@ export async function exportDB(userId: number, knex: Knex): Promise<DBImportExpo
tracksArtists.forEach((v: [number, number]) => { tracksArtists.forEach((v: [number, number]) => {
let [trackId, artistId] = v; let [trackId, artistId] = v;
tracks.find((t: TrackWithRefsWithId) => t.id === trackId)?.artistIds.push(artistId); tracks.find((t: TrackWithRefsWithId) => t.id === trackId)?.artistIds.push(artistId);
artists.find((a: ArtistWithRefsWithId) => a.id === artistId)?.trackIds.push(trackId);
})
tracks.forEach((t: api.TrackWithRefsWithId) => {
albums.find((a: AlbumWithRefsWithId) => t.albumId && a.id === t.albumId)?.trackIds.push(t.id);
}) })
tracksTags.forEach((v: [number, number]) => { tracksTags.forEach((v: [number, number]) => {
let [trackId, tagId] = v; let [trackId, tagId] = v;
@ -164,14 +154,13 @@ export async function exportDB(userId: number, knex: Knex): Promise<DBImportExpo
} }
} }
export async function importDB(userId: number, db: DBImportExportFormat, knex: Knex): Promise<void> { export async function importDB(userId: number, db: api.DBImportExportFormat, knex: Knex): Promise<void> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
// Store the ID mappings in this record. // Store the ID mappings in this record.
let tagIdMaps: Record<number, number> = {}; let tagIdMaps: Record<number, number> = {};
let artistIdMaps: Record<number, number> = {}; let artistIdMaps: Record<number, number> = {};
let albumIdMaps: Record<number, number> = {}; let albumIdMaps: Record<number, number> = {};
let trackIdMaps: Record<number, number> = {}; let trackIdMaps: Record<number, number> = {};
try {
// Insert items one by one, remapping the IDs as we go. // Insert items one by one, remapping the IDs as we go.
await Promise.all(db.tags.map((tag: TagWithRefsWithId) => async () => { await Promise.all(db.tags.map((tag: TagWithRefsWithId) => async () => {
tagIdMaps[tag.id] = await createTag(userId, tag, knex); tagIdMaps[tag.id] = await createTag(userId, tag, knex);
@ -197,8 +186,5 @@ export async function importDB(userId: number, db: DBImportExportFormat, knex: K
albumId: track.albumId ? albumIdMaps[track.albumId] : null, albumId: track.albumId ? albumIdMaps[track.albumId] : null,
}, knex); }, knex);
})) }))
} catch (e) {
trx.rollback();
}
}); });
} }

@ -6,7 +6,6 @@ import { IntegrationDataWithId, IntegrationDataWithSecret, PartialIntegrationDat
export async function createIntegration(userId: number, integration: api.IntegrationDataWithSecret, knex: Knex): Promise<number> { export async function createIntegration(userId: number, integration: api.IntegrationDataWithSecret, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
try {
// Create the new integration. // Create the new integration.
var integration: any = { var integration: any = {
name: integration.name, name: integration.name,
@ -21,10 +20,6 @@ export async function createIntegration(userId: number, integration: api.Integra
)[0]; )[0];
return integrationId; return integrationId;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
@ -71,7 +66,6 @@ export async function listIntegrations(userId: number, knex: Knex): Promise<api.
export async function deleteIntegration(userId: number, id: number, knex: Knex) { export async function deleteIntegration(userId: number, id: number, knex: Knex) {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the integration itself. // Start retrieving the integration itself.
const integrationId = await trx.select('id') const integrationId = await trx.select('id')
.from('integrations') .from('integrations')
@ -93,16 +87,11 @@ export async function deleteIntegration(userId: number, id: number, knex: Knex)
await trx('integrations') await trx('integrations')
.where({ 'user': userId, 'id': integrationId }) .where({ 'user': userId, 'id': integrationId })
.del(); .del();
} catch (e) {
trx.rollback();
}
}) })
} }
export async function modifyIntegration(userId: number, id: number, integration: PartialIntegrationData, knex: Knex): Promise<void> { export async function modifyIntegration(userId: number, id: number, integration: PartialIntegrationData, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the integration. // Start retrieving the integration.
const integrationId = await trx.select('id') const integrationId = await trx.select('id')
.from('integrations') .from('integrations')
@ -128,8 +117,5 @@ export async function modifyIntegration(userId: number, id: number, integration:
await trx('integrations') await trx('integrations')
.where({ 'user': userId, 'id': id }) .where({ 'user': userId, 'id': id })
.update(update) .update(update)
} catch (e) {
trx.rollback();
}
}) })
} }

@ -22,7 +22,7 @@ export function toApiArtist(dbObj: any): api.Artist {
}; };
} }
export function toApiTrack(dbObj: any, artists: any[], tags: any[], album: any | undefined): api.Track { export function toApiTrack(dbObj: any, artists: any[], tags: any[], albums: any[]): api.Track {
return <api.Track>{ return <api.Track>{
mbApi_typename: "track", mbApi_typename: "track",
trackId: dbObj['tracks.id'], trackId: dbObj['tracks.id'],
@ -34,7 +34,7 @@ export function toApiTrack(dbObj: any, artists: any[], tags: any[], album: any |
tags: tags.map((tag: any) => { tags: tags.map((tag: any) => {
return toApiTag(tag); return toApiTag(tag);
}), }),
album: album, album: albums.length > 0 ? toApiAlbum(albums[0]) : null,
} }
} }
@ -80,23 +80,34 @@ const objectTables: Record<ObjectType, string> = {
// To keep track of linking tables between objects. // To keep track of linking tables between objects.
const linkingTables: any = [ const linkingTables: any = [
[[ObjectType.Track, ObjectType.Album], 'tracks_albums'],
[[ObjectType.Track, ObjectType.Artist], 'tracks_artists'], [[ObjectType.Track, ObjectType.Artist], 'tracks_artists'],
[[ObjectType.Track, ObjectType.Tag], 'tracks_tags'], [[ObjectType.Track, ObjectType.Tag], 'tracks_tags'],
[[ObjectType.Artist, ObjectType.Album], 'artists_albums'], [[ObjectType.Artist, ObjectType.Album], 'artists_albums'],
[[ObjectType.Artist, ObjectType.Tag], 'artists_tags'], [[ObjectType.Artist, ObjectType.Tag], 'artists_tags'],
[[ObjectType.Album, ObjectType.Tag], 'albums_tags'], [[ObjectType.Album, ObjectType.Tag], 'albums_tags'],
] ]
function getLinkingTable(a: ObjectType, b: ObjectType): string { function getLinkingTable(a: ObjectType, b: ObjectType): string | undefined {
var res: string | undefined = undefined; var res: string | undefined = undefined;
linkingTables.forEach((row: any) => { linkingTables.forEach((row: any) => {
if (row[0].includes(a) && row[0].includes(b)) { if (row[0].includes(a) && row[0].includes(b)) {
res = row[1]; res = row[1];
} }
}) })
if (res) return res; return res;
}
throw "Could not find linking table for objects: " + JSON.stringify(a) + ", " + JSON.stringify(b); // To keep track of linking columns between objects.
const linkingColumns: any = [
[[ObjectType.Track, ObjectType.Album], 'tracks.album'],
]
function getLinkingColumn(a: ObjectType, b: ObjectType): string | undefined {
var res: string | undefined = undefined;
linkingColumns.forEach((row: any) => {
if (row[0].includes(a) && row[0].includes(b)) {
res = row[1];
}
})
return res;
} }
// To keep track of ID fields used in linking tables. // To keep track of ID fields used in linking tables.
@ -124,11 +135,18 @@ function getRequiredDatabaseObjects(queryElem: api.QueryElem): Set<ObjectType> {
function addJoin(knexQuery: any, base: ObjectType, other: ObjectType) { function addJoin(knexQuery: any, base: ObjectType, other: ObjectType) {
const linkTable = getLinkingTable(base, other); const linkTable = getLinkingTable(base, other);
const linkColumn = getLinkingColumn(base, other);
const baseTable = objectTables[base]; const baseTable = objectTables[base];
const otherTable = objectTables[other]; const otherTable = objectTables[other];
if (linkTable) {
return knexQuery return knexQuery
.join(linkTable, { [baseTable + '.id']: linkTable + '.' + linkingTableIdNames[base] }) .join(linkTable, { [baseTable + '.id']: linkTable + '.' + linkingTableIdNames[base] })
.join(otherTable, { [otherTable + '.id']: linkTable + '.' + linkingTableIdNames[other] }); .join(otherTable, { [otherTable + '.id']: linkTable + '.' + linkingTableIdNames[other] });
} else if (linkColumn) {
return knexQuery
.join(otherTable, { [linkColumn]: otherTable + '.id' });
}
} }
enum WhereType { enum WhereType {
@ -231,7 +249,7 @@ function getWhere(queryElem: api.QueryElem): string {
} }
const objectColumns = { const objectColumns = {
[ObjectType.Track]: ['tracks.id as tracks.id', 'tracks.title as tracks.title', 'tracks.storeLinks as tracks.storeLinks'], [ObjectType.Track]: ['tracks.id as tracks.id', 'tracks.name as tracks.name', 'tracks.storeLinks as tracks.storeLinks', 'tracks.album as tracks.album'],
[ObjectType.Artist]: ['artists.id as artists.id', 'artists.name as artists.name', 'artists.storeLinks as artists.storeLinks'], [ObjectType.Artist]: ['artists.id as artists.id', 'artists.name as artists.name', 'artists.storeLinks as artists.storeLinks'],
[ObjectType.Album]: ['albums.id as albums.id', 'albums.name as albums.name', 'albums.storeLinks as albums.storeLinks'], [ObjectType.Album]: ['albums.id as albums.id', 'albums.name as albums.name', 'albums.storeLinks as albums.storeLinks'],
[ObjectType.Tag]: ['tags.id as tags.id', 'tags.name as tags.name', 'tags.parentId as tags.parentId'] [ObjectType.Tag]: ['tags.id as tags.id', 'tags.name as tags.name', 'tags.parentId as tags.parentId']
@ -267,7 +285,7 @@ function constructQuery(knex: Knex, userId: number, queryFor: ObjectType, queryE
// Apply ordering // Apply ordering
const orderKeys = { const orderKeys = {
[api.OrderByType.Name]: objectTables[queryFor] + '.' + ((queryFor === ObjectType.Track) ? 'title' : 'name') [api.OrderByType.Name]: objectTables[queryFor] + '.' + ((queryFor === ObjectType.Track) ? 'name' : 'name')
}; };
q = q.orderBy(orderKeys[ordering.orderBy.type], q = q.orderBy(orderKeys[ordering.orderBy.type],
(ordering.ascending ? 'asc' : 'desc')); (ordering.ascending ? 'asc' : 'desc'));
@ -285,17 +303,33 @@ function constructQuery(knex: Knex, userId: number, queryFor: ObjectType, queryE
async function getLinkedObjects(knex: Knex, userId: number, base: ObjectType, linked: ObjectType, baseIds: number[]) { async function getLinkedObjects(knex: Knex, userId: number, base: ObjectType, linked: ObjectType, baseIds: number[]) {
var result: Record<number, any[]> = {}; var result: Record<number, any[]> = {};
const table = objectTables[base];
const otherTable = objectTables[linked]; const otherTable = objectTables[linked];
const linkingTable = getLinkingTable(base, linked); const maybeLinkingTable = getLinkingTable(base, linked);
const maybeLinkingColumn = getLinkingColumn(base, linked);
const columns = objectColumns[linked]; const columns = objectColumns[linked];
console.log(table, otherTable, maybeLinkingTable, maybeLinkingColumn);
if (maybeLinkingTable) {
await Promise.all(baseIds.map((baseId: number) => { await Promise.all(baseIds.map((baseId: number) => {
return knex.select(columns).groupBy(otherTable + '.id').from(otherTable) return knex.select(columns).groupBy(otherTable + '.id').from(otherTable)
.join(linkingTable, { [linkingTable + '.' + linkingTableIdNames[linked]]: otherTable + '.id' }) .join(maybeLinkingTable, { [maybeLinkingTable + '.' + linkingTableIdNames[linked]]: otherTable + '.id' })
.where({ [otherTable + '.user']: userId }) .where({ [otherTable + '.user']: userId })
.where({ [linkingTable + '.' + linkingTableIdNames[base]]: baseId }) .where({ [maybeLinkingTable + '.' + linkingTableIdNames[base]]: baseId })
.then((others: any) => { result[baseId] = others; }) .then((others: any) => { result[baseId] = others; })
})) }))
} else if (maybeLinkingColumn) {
await Promise.all(baseIds.map((baseId: number) => {
return knex.select(columns).groupBy(otherTable + '.id').from(otherTable)
.join(table, { [maybeLinkingColumn]: otherTable + '.id' })
.where({ [otherTable + '.user']: userId })
.where({ [table + '.id']: baseId })
.then((others: any) => { result[baseId] = others; })
}))
} else {
throw new Error('canno link objects.')
}
console.log("Query results for", baseIds, ":", result); console.log("Query results for", baseIds, ":", result);
return result; return result;
@ -344,7 +378,7 @@ export async function doQuery(userId: number, q: api.QueryRequest, knex: Knex):
ObjectType.Album, ObjectType.Album,
q.query, q.query,
q.ordering, q.ordering,
artistOffset || 0, albumOffset || 0,
albumLimit >= 0 ? albumLimit : null, albumLimit >= 0 ? albumLimit : null,
) : ) :
(async () => [])(); (async () => [])();
@ -472,5 +506,6 @@ export async function doQuery(userId: number, q: api.QueryRequest, knex: Knex):
} }
} }
console.log("Query response:", response)
return response; return response;
} }

@ -1,4 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { isConstructorDeclaration } from "typescript";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import { TagBaseWithRefs, TagWithDetails, TagWithId, TagWithRefs, TagWithRefsWithId } from "../../client/src/api/api"; import { TagBaseWithRefs, TagWithDetails, TagWithId, TagWithRefs, TagWithRefsWithId } from "../../client/src/api/api";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
@ -24,7 +25,6 @@ export async function getTagChildrenRecursive(id: number, userId: number, trx: a
// Returns the id of the created tag. // Returns the id of the created tag.
export async function createTag(userId: number, tag: TagWithRefs, knex: Knex): Promise<number> { export async function createTag(userId: number, tag: TagWithRefs, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
try {
// If applicable, retrieve the parent tag. // If applicable, retrieve the parent tag.
const maybeParent: number | null = const maybeParent: number | null =
tag.parentId ? tag.parentId ?
@ -45,31 +45,25 @@ export async function createTag(userId: number, tag: TagWithRefs, knex: Knex): P
} }
// Create the new tag. // Create the new tag.
var tag: any = { var newTag: any = {
name: tag.name, name: tag.name,
user: userId, user: userId,
}; };
if (maybeParent) { if (maybeParent) {
tag['parentId'] = maybeParent; newTag['parentId'] = maybeParent;
} }
const tagId = (await trx('tags') const tagId = (await trx('tags')
.insert(tag) .insert(newTag)
.returning('id') // Needed for Postgres .returning('id') // Needed for Postgres
)[0]; )[0];
return tagId; return tagId;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function deleteTag(userId: number, tagId: number, knex: Knex) { export async function deleteTag(userId: number, tagId: number, knex: Knex) {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving any child tags. // Start retrieving any child tags.
const childTagsPromise = const childTagsPromise =
getTagChildrenRecursive(tagId, userId, trx); getTagChildrenRecursive(tagId, userId, trx);
@ -123,10 +117,6 @@ export async function deleteTag(userId: number, tagId: number, knex: Knex) {
.del(); .del();
await Promise.all([deleteArtistsPromise, deleteAlbumsPromise, deleteTracksPromise, deleteTags]) await Promise.all([deleteArtistsPromise, deleteAlbumsPromise, deleteTracksPromise, deleteTags])
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
@ -168,7 +158,6 @@ export async function getTag(userId: number, tagId: number, knex: Knex): Promise
export async function modifyTag(userId: number, tagId: number, tag: TagBaseWithRefs, knex: Knex): Promise<void> { export async function modifyTag(userId: number, tagId: number, tag: TagBaseWithRefs, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the parent tag. // Start retrieving the parent tag.
const parentTagIdPromise: Promise<number | undefined | null> = tag.parentId ? const parentTagIdPromise: Promise<number | undefined | null> = tag.parentId ?
trx.select('id') trx.select('id')
@ -207,17 +196,11 @@ export async function modifyTag(userId: number, tagId: number, tag: TagBaseWithR
name: tag.name, name: tag.name,
parentId: tag.parentId || null, parentId: tag.parentId || null,
}) })
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function mergeTag(userId: number, fromId: number, toId: number, knex: Knex): Promise<void> { export async function mergeTag(userId: number, fromId: number, toId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the "from" tag. // Start retrieving the "from" tag.
const fromTagIdPromise = trx.select('id') const fromTagIdPromise = trx.select('id')
.from('tags') .from('tags')
@ -266,9 +249,5 @@ export async function mergeTag(userId: number, fromId: number, toId: number, kne
.where({ 'user': userId }) .where({ 'user': userId })
.where({ 'id': fromId }) .where({ 'id': fromId })
.del(); .del();
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }

@ -3,6 +3,7 @@ import { TrackBaseWithRefs, TrackWithDetails, TrackWithRefs } from "../../client
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
var _ = require('lodash')
// Returns an track with details, or null if not found. // Returns an track with details, or null if not found.
export async function getTrack(id: number, userId: number, knex: Knex): export async function getTrack(id: number, userId: number, knex: Knex):
@ -76,7 +77,6 @@ export async function getTrack(id: number, userId: number, knex: Knex):
// Returns the id of the created track. // Returns the id of the created track.
export async function createTrack(userId: number, track: TrackWithRefs, knex: Knex): Promise<number> { export async function createTrack(userId: number, track: TrackWithRefs, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
try {
// Start retrieving artists. // Start retrieving artists.
const artistIdsPromise: Promise<number[]> = const artistIdsPromise: Promise<number[]> =
trx.select('id') trx.select('id')
@ -95,21 +95,23 @@ export async function createTrack(userId: number, track: TrackWithRefs, knex: Kn
// Start retrieving album. // Start retrieving album.
const albumIdPromise: Promise<number | null> = const albumIdPromise: Promise<number | null> =
knex.select('id') track.albumId ?
trx.select('id')
.from('albums') .from('albums')
.where({ 'user': userId, 'albumId': track.albumId }) .where({ 'user': userId, 'id': track.albumId })
.then((albums: any) => albums.map((album: any) => album['albumId'])) .then((albums: any) => albums.map((album: any) => album['id']))
.then((ids: number[]) => .then((ids: number[]) =>
ids.length > 0 ? ids[0] : (() => null)() ids.length > 0 ? ids[0] : (() => null)()
); ) :
(async () => null)();
// Wait for the requests to finish. // Wait for the requests to finish.
var [artists, tags, album] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdPromise]);; var [artists, tags, album] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdPromise]);
// Check that we found all artists and tags we need. // Check that we found all artists and tags we need.
if ((new Set((artists as number[]).map((a: any) => a['id'])) !== new Set(track.artistIds)) || if (!_.isEqual((artists as number[]).sort(), track.artistIds.sort()) ||
(new Set((tags as number[]).map((a: any) => a['id'])) !== new Set(track.tagIds)) || (!_.isEqual((tags as number[]).sort(), track.tagIds.sort())) ||
(album === null)) { (track.albumId && (album === null))) {
const e: DBError = { const e: DBError = {
name: "DBError", name: "DBError",
kind: DBErrorKind.ResourceNotFound, kind: DBErrorKind.ResourceNotFound,
@ -124,14 +126,14 @@ export async function createTrack(userId: number, track: TrackWithRefs, knex: Kn
name: track.name, name: track.name,
storeLinks: JSON.stringify(track.storeLinks || []), storeLinks: JSON.stringify(track.storeLinks || []),
user: userId, user: userId,
albumId: album, album: album || null,
}) })
.returning('id') // Needed for Postgres .returning('id') // Needed for Postgres
)[0]; )[0];
// Link the artists via the linking table. // Link the artists via the linking table.
if (artists && artists.length) { if (artists && artists.length) {
await trx('artists_tracks').insert( await trx('tracks_artists').insert(
artists.map((artistId: number) => { artists.map((artistId: number) => {
return { return {
artistId: artistId, artistId: artistId,
@ -154,17 +156,11 @@ export async function createTrack(userId: number, track: TrackWithRefs, knex: Kn
} }
return trackId; return trackId;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function modifyTrack(userId: number, trackId: number, track: TrackBaseWithRefs, knex: Knex): Promise<void> { export async function modifyTrack(userId: number, trackId: number, track: TrackBaseWithRefs, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start retrieving the track itself. // Start retrieving the track itself.
const trackIdPromise: Promise<number | undefined> = const trackIdPromise: Promise<number | undefined> =
trx.select('id') trx.select('id')
@ -195,8 +191,8 @@ export async function modifyTrack(userId: number, trackId: number, track: TrackB
var [oldTrack, artists, tags] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise]);; var [oldTrack, artists, tags] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise]);;
// Check that we found all objects we need. // Check that we found all objects we need.
if ((!artists || new Set(artists.map((a: any) => a['id'])) !== new Set(track.artistIds)) || if ((!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort())) ||
(!tags || new Set(tags.map((a: any) => a['id'])) !== new Set(track.tagIds)) || (!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort())) ||
!oldTrack) { !oldTrack) {
const e: DBError = { const e: DBError = {
name: "DBError", name: "DBError",
@ -287,17 +283,11 @@ export async function modifyTrack(userId: number, trackId: number, track: TrackB
]); ]);
return; return;
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }
export async function deleteTrack(userId: number, trackId: number, knex: Knex): Promise<void> { export async function deleteTrack(userId: number, trackId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => { await knex.transaction(async (trx) => {
try {
// Start by retrieving the track itself for sanity. // Start by retrieving the track itself for sanity.
const confirmTrackId: number | undefined = const confirmTrackId: number | undefined =
await trx.select('id') await trx.select('id')
@ -335,9 +325,5 @@ export async function deleteTrack(userId: number, trackId: number, knex: Knex):
// Wait for the requests to finish. // Wait for the requests to finish.
await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTrackPromise]); await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTrackPromise]);
} catch (e) {
trx.rollback();
throw e;
}
}) })
} }

@ -6,7 +6,6 @@ import { DBErrorKind, DBError } from '../endpoints/types';
export async function createUser(user: api.User, knex: Knex): Promise<number> { export async function createUser(user: api.User, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
try {
// check if the user already exists // check if the user already exists
const newUser = (await trx const newUser = (await trx
.select('id') .select('id')
@ -32,8 +31,5 @@ export async function createUser(user: api.User, knex: Knex): Promise<number> {
)[0]; )[0];
return userId; return userId;
} catch (e) {
trx.rollback();
}
}) })
} }

@ -25,10 +25,10 @@ export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex)
} }
export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostAlbumRequest(req)) { if (!api.checkPostAlbumRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PostAlbum request', message: 'Invalid PostAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
@ -40,14 +40,14 @@ export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex)
try { try {
let id = await createAlbum(userId, reqObject, knex); let id = await createAlbum(userId, reqObject, knex);
res.status(200).send(id); res.status(200).send({ id: id });
} catch (e) { } catch (e) {
handleErrorsInEndpoint(e); handleErrorsInEndpoint(e);
} }
} }
export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutAlbumRequest(req)) { if (!api.checkPutAlbumRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PutAlbum request', message: 'Invalid PutAlbum request',
@ -69,7 +69,7 @@ export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex)
} }
export const PatchAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PatchAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchAlbumRequest(req)) { if (!api.checkPatchAlbumRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PatchAlbum request', message: 'Invalid PatchAlbum request',

@ -16,10 +16,10 @@ export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex)
} }
export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostArtistRequest(req)) { if (!api.checkPostArtistRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PostArtist request', message: 'Invalid PostArtist request: ' + JSON.stringify(req.body),
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
@ -40,7 +40,7 @@ export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex
export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutArtistRequest(req)) { if (!api.checkPutArtistRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PutArtist request', message: 'Invalid PutArtist request',
@ -63,7 +63,7 @@ export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex)
} }
export const PatchArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PatchArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchArtistRequest(req)) { if (!api.checkPatchArtistRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PatchArtist request', message: 'Invalid PatchArtist request',

@ -0,0 +1,45 @@
import Knex from "knex";
import { exportDB, importDB } from "../db/ImportExport";
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from "./types";
import * as api from '../../client/src/api/api';
import { DBExportEndpoint } from "../../client/src/api/api";
export const DBExport: EndpointHandler = async (req: any, res: any, knex: Knex) => {
try {
let db = await exportDB(req.user.id, knex);
res.status(200);
res.setHeader('Content-disposition', 'attachment; filename= mudbase.json');
res.setHeader('Content-type', 'application/json');
res.send( JSON.stringify(db, null, 2) );
} catch (e) {
handleErrorsInEndpoint(e)
}
}
export const DBImport: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDBImportRequest(req.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid DBImport request',
httpStatus: 400
};
throw e;
}
const reqObject: api.DBImportRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Import DB ");
try {
await importDB(userId, reqObject, knex)
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const importExportEndpoints: [ string, string, boolean, EndpointHandler ][] = [
[ api.DBExportEndpoint, 'get', true, DBExport ],
[ api.DBImportEndpoint, 'post', true, DBImport ],
];

@ -6,7 +6,7 @@ import { createIntegration, deleteIntegration, getIntegration, listIntegrations,
import { IntegrationDataWithId } from '../../client/src/api/api'; import { IntegrationDataWithId } from '../../client/src/api/api';
export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostIntegrationRequest(req)) { if (!api.checkPostIntegrationRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PostIntegration request', message: 'Invalid PostIntegration request',
@ -67,7 +67,7 @@ export const DeleteIntegration: EndpointHandler = async (req: any, res: any, kne
} }
export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutIntegrationRequest(req)) { if (!api.checkPutIntegrationRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PutIntegration request', message: 'Invalid PutIntegration request',
@ -90,7 +90,7 @@ export const PutIntegration: EndpointHandler = async (req: any, res: any, knex:
} }
export const PatchIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PatchIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchIntegrationRequest(req)) { if (!api.checkPatchIntegrationRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PatchIntegration request', message: 'Invalid PatchIntegration request',

@ -18,7 +18,7 @@ export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) =>
console.log("User ", userId, ": Query ", reqObject); console.log("User ", userId, ": Query ", reqObject);
try { try {
let r = doQuery(userId, reqObject, knex); let r = await doQuery(userId, reqObject, knex);
res.status(200).send(r); res.status(200).send(r);
} catch (e) { } catch (e) {
handleErrorsInEndpoint(e); handleErrorsInEndpoint(e);

@ -2,12 +2,13 @@ import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex'; import Knex from 'knex';
import { createTag, deleteTag, getTag, mergeTag, modifyTag } from '../db/Tag'; import { createTag, deleteTag, getTag, mergeTag, modifyTag } from '../db/Tag';
import { getAllJSDocTagsOfKind } from 'typescript';
export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostTagRequest(req)) { if (!api.checkPostTagRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PostTag request', message: 'Invalid PostTag request' + JSON.stringify(req.body),
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
@ -55,7 +56,7 @@ export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) =>
} }
export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutTagRequest(req)) { if (!api.checkPutTagRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PutTag request', message: 'Invalid PutTag request',
@ -77,7 +78,7 @@ export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) =>
} }
export const PatchTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PatchTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchTagRequest(req)) { if (!api.checkPatchTagRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PatchTag request', message: 'Invalid PatchTag request',

@ -5,7 +5,7 @@ import asJson from '../lib/asJson';
import { createTrack, deleteTrack, getTrack, modifyTrack } from '../db/Track'; import { createTrack, deleteTrack, getTrack, modifyTrack } from '../db/Track';
export const PostTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostTrackRequest(req)) { if (!api.checkPostTrackRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PostTrack request', message: 'Invalid PostTrack request',
@ -19,8 +19,9 @@ export const PostTrack: EndpointHandler = async (req: any, res: any, knex: Knex)
console.log("User ", userId, ": Post Track ", reqObject); console.log("User ", userId, ": Post Track ", reqObject);
try { try {
let id = await createTrack(userId, reqObject, knex);
res.status(200).send({ res.status(200).send({
id: await createTrack(userId, reqObject, knex) id: id,
}); });
} catch (e) { } catch (e) {
@ -40,7 +41,7 @@ export const GetTrack: EndpointHandler = async (req: any, res: any, knex: Knex)
} }
export const PutTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutTrackRequest(req)) { if (!api.checkPutTrackRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PutTrack request', message: 'Invalid PutTrack request',
@ -62,7 +63,7 @@ export const PutTrack: EndpointHandler = async (req: any, res: any, knex: Knex)
} }
export const PatchTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PatchTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchTrackRequest(req)) { if (!api.checkPatchTrackRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
name: "EndpointError", name: "EndpointError",
message: 'Invalid PatchTrack request', message: 'Invalid PatchTrack request',

@ -9,8 +9,8 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.string('name'); table.string('name');
table.string('storeLinks') table.string('storeLinks')
table.integer('user').unsigned().notNullable().defaultTo(1); table.integer('user').unsigned().notNullable().defaultTo(1).references('users.id');
table.integer('album').unsigned().defaultTo(null); table.integer('album').unsigned().defaultTo(null).references('albums.id');
} }
) )
@ -21,7 +21,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.string('name'); table.string('name');
table.string('storeLinks'); table.string('storeLinks');
table.integer('user').unsigned().notNullable().defaultTo(1); table.integer('user').unsigned().notNullable().defaultTo(1).references('users.id');
} }
) )
@ -32,7 +32,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.string('name'); table.string('name');
table.string('storeLinks'); table.string('storeLinks');
table.integer('user').unsigned().notNullable().defaultTo(1); table.integer('user').unsigned().notNullable().defaultTo(1).references('users.id');
} }
) )
@ -43,7 +43,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.string('name'); table.string('name');
table.integer('parentId'); table.integer('parentId');
table.integer('user').unsigned().notNullable().defaultTo(1); table.integer('user').unsigned().notNullable().defaultTo(1).references('users.id');
} }
) )
@ -62,7 +62,7 @@ export async function up(knex: Knex): Promise<void> {
'integrations', 'integrations',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('user').unsigned().notNullable().defaultTo(1); table.integer('user').unsigned().notNullable().defaultTo(1).references('users.id');
table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user. table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user.
table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify) table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify)
table.string('details'); // Stores anything that might be needed for the integration to work. table.string('details'); // Stores anything that might be needed for the integration to work.
@ -76,8 +76,8 @@ export async function up(knex: Knex): Promise<void> {
'tracks_artists', 'tracks_artists',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('trackId'); table.integer('trackId').references('tracks.id');
table.integer('artistId'); table.integer('artistId').references('artists.id');
table.unique(['trackId', 'artistId']) table.unique(['trackId', 'artistId'])
} }
) )
@ -87,8 +87,8 @@ export async function up(knex: Knex): Promise<void> {
'tracks_tags', 'tracks_tags',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('trackId'); table.integer('trackId').references('tracks.id');
table.integer('tagId'); table.integer('tagId').references('tags.id');
table.unique(['trackId', 'tagId']) table.unique(['trackId', 'tagId'])
} }
) )
@ -98,8 +98,8 @@ export async function up(knex: Knex): Promise<void> {
'artists_tags', 'artists_tags',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('artistId'); table.integer('artistId').references('artists.id');
table.integer('tagId'); table.integer('tagId').references('tags.id');
table.unique(['artistId', 'tagId']) table.unique(['artistId', 'tagId'])
} }
) )
@ -109,8 +109,8 @@ export async function up(knex: Knex): Promise<void> {
'albums_tags', 'albums_tags',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('tagId'); table.integer('tagId').references('tags.id');
table.integer('albumId'); table.integer('albumId').references('albums.id');
table.unique(['albumId', 'tagId']) table.unique(['albumId', 'tagId'])
} }
) )
@ -120,8 +120,8 @@ export async function up(knex: Knex): Promise<void> {
'artists_albums', 'artists_albums',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('artistId'); table.integer('artistId').references('artists.id');
table.integer('albumId'); table.integer('albumId').references('albums.id');
table.unique(['artistId', 'albumId']) table.unique(['artistId', 'albumId'])
} }
) )

@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "ts-node server.ts", "start": "ts-node server.ts",
"dev": "nodemon server.ts", "dev": "API='/api' nodemon server.ts",
"build": "tsc", "build": "tsc",
"test": "ts-node node_modules/jasmine/bin/jasmine --config=test/jasmine.json" "test": "ts-node node_modules/jasmine/bin/jasmine --config=test/jasmine.json"
}, },

Loading…
Cancel
Save