diff --git a/client/package-lock.json b/client/package-lock.json index 0e5418c..c3f1ea8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1837,6 +1837,14 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -6910,14 +6918,55 @@ } }, "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==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.0.6.tgz", + "integrity": "sha512-NyL6ZB6cVni7pl+/IT2W0ni5ME00xR0sN27AQZZrpKn1b+qRh+mLbBxIq9Cq1oGfmTc7BUq4HB77mxwCaxAYNg==", "requires": { - "http-proxy": "^1.17.0", - "is-glob": "^4.0.0", - "lodash": "^4.17.11", - "micromatch": "^3.1.10" + "@types/http-proxy": "^1.17.4", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "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": { @@ -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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", diff --git a/client/package.json b/client/package.json index 28f7ee6..f0412e6 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "@types/react-router-dom": "^5.1.5", "@types/tiny-async-pool": "^1.0.0", "@types/uuid": "^8.3.0", + "http-proxy-middleware": "^1.0.6", "jsurl": "^0.1.5", "lodash": "^4.17.20", "material-table": "^1.69.0", @@ -34,7 +35,7 @@ "uuid": "^8.3.0" }, "scripts": { - "dev": "BROWSER=none react-scripts start", + "dev": "REACT_APP_BACKEND='/api' BROWSER=none react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" @@ -53,6 +54,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "proxy": "http://localhost:5000/" + } } diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 7cc54af..2862622 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -9,5 +9,6 @@ export * from './types/resources'; export * from './endpoints/auth'; +export * from './endpoints/importexport'; export * from './endpoints/resources'; export * from './endpoints/query'; \ No newline at end of file diff --git a/client/src/api/endpoints/importexport.ts b/client/src/api/endpoints/importexport.ts new file mode 100644 index 0000000..d7432ee --- /dev/null +++ b/client/src/api/endpoints/importexport.ts @@ -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); +} diff --git a/client/src/api/types/resources.ts b/client/src/api/types/resources.ts index fdadd35..99eb4f4 100644 --- a/client/src/api/types/resources.ts +++ b/client/src/api/types/resources.ts @@ -49,7 +49,7 @@ export function isTrackBase(q: any): q is TrackBase { return q.mbApi_typename && q.mbApi_typename === "track"; } 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 { return isTrackBaseWithRefs(q) && "name" in q; @@ -65,10 +65,12 @@ export interface ArtistBase { export interface ArtistBaseWithRefs extends ArtistBase { albumIds?: number[], tagIds?: number[], + trackIds?: number[], } export interface ArtistBaseWithDetails extends ArtistBase { albums: AlbumWithId[], tags: TagWithId[], + tracks: TrackWithId[], } export interface ArtistWithDetails extends ArtistBaseWithDetails { name: string, @@ -77,6 +79,7 @@ export interface ArtistWithRefs extends ArtistBaseWithRefs { name: string, albumIds: number[], tagIds: number[], + trackIds: number[], } export interface Artist extends ArtistBase { name: string, @@ -94,10 +97,10 @@ export function isArtistBase(q: any): q is ArtistBase { return q.mbApi_typename && q.mbApi_typename === "artist"; } 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 { - 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"; } 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 { 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"; } 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 { + console.log("Check", q) return isTagBaseWithRefs(q) && "name" in q; } diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 8b82c98..9270c6b 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -17,6 +17,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { ProvideIntegrations } from '../lib/integration/useIntegrations'; import ManageLinksWindow from './windows/manage_links/ManageLinksWindow'; import ManageWindow, { ManageWhat } from './windows/manage/ManageWindow'; +import TrackWindow from './windows/track/TrackWindow'; const darkTheme = createMuiTheme({ palette: { @@ -84,9 +85,9 @@ export default function MainWindow(props: any) { - + - + @@ -96,6 +97,10 @@ export default function MainWindow(props: any) { + + + + diff --git a/client/src/components/tables/ResultsTable.tsx b/client/src/components/tables/ResultsTable.tsx index f581bdf..4f605d3 100644 --- a/client/src/components/tables/ResultsTable.tsx +++ b/client/src/components/tables/ResultsTable.tsx @@ -4,12 +4,12 @@ import stringifyList from '../../lib/stringifyList'; import { useHistory } from 'react-router'; export interface TrackGetters { - getTitle: (track: any) => string, + getName: (track: any) => string, getId: (track: any) => number, getArtistNames: (track: any) => string[], getArtistIds: (track: any) => number[], - getAlbumNames: (track: any) => string[], - getAlbumIds: (track: any) => number[], + getAlbumName: (track: any) => string | undefined, + getAlbumId: (track: any) => number | undefined, 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. } @@ -45,14 +45,13 @@ export default function TrackTable(props: { {props.tracks.map((track: any) => { - const title = props.trackGetters.getTitle(track); + const name = props.trackGetters.getName(track); // TODO: display artists and albums separately! const artistNames = props.trackGetters.getArtistNames(track); const artist = stringifyList(artistNames); const mainArtistId = props.trackGetters.getArtistIds(track)[0]; - const albumNames = props.trackGetters.getAlbumNames(track); - const album = stringifyList(albumNames); - const mainAlbumId = props.trackGetters.getAlbumIds(track)[0]; + const album = props.trackGetters.getAlbumName(track); + const albumId = props.trackGetters.getAlbumId(track); const trackId = props.trackGetters.getId(track); const tagIds = props.trackGetters.getTagIds(track); @@ -61,7 +60,7 @@ export default function TrackTable(props: { } const onClickAlbum = () => { - history.push('/album/' + mainAlbumId); + history.push('/album/' + albumId || ''); } const onClickTrack = () => { @@ -99,10 +98,10 @@ export default function TrackTable(props: { ; } - return - {title} + return + {name} {artist} - {album} + {album ? {album} : } {tags} diff --git a/client/src/components/windows/manage/ManageWindow.tsx b/client/src/components/windows/manage/ManageWindow.tsx index 0928001..edf83e4 100644 --- a/client/src/components/windows/manage/ManageWindow.tsx +++ b/client/src/components/windows/manage/ManageWindow.tsx @@ -7,12 +7,15 @@ import Alert from '@material-ui/lab/Alert'; import { Link } from 'react-router-dom'; import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import SaveIcon from '@material-ui/icons/Save'; import ManageLinksWindow from '../manage_links/ManageLinksWindow'; import ManageTagsWindow from '../manage_tags/ManageTagsWindow'; +import ManageImportExportWindow from '../manage_importexport/ManageImportExport'; export enum ManageWhat { Tags = 0, Links, + ImportExport, } export default function ManageWindow(props: { @@ -52,8 +55,15 @@ export default function ManageWindow(props: { selected={props.selectedWindow === ManageWhat.Links} onClick={() => history.push('/manage/links')} /> + } + selected={props.selectedWindow === ManageWhat.ImportExport} + onClick={() => history.push('/manage/importexport')} + /> {props.selectedWindow === ManageWhat.Tags && } {props.selectedWindow === ManageWhat.Links && } + {props.selectedWindow === ManageWhat.ImportExport && } } \ No newline at end of file diff --git a/client/src/components/windows/manage_importexport/ManageImportExport.tsx b/client/src/components/windows/manage_importexport/ManageImportExport.tsx new file mode 100644 index 0000000..e2d5cdf --- /dev/null +++ b/client/src/components/windows/manage_importexport/ManageImportExport.tsx @@ -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 +} + +export function ManageImportExportWindowControlled(props: { + state: ManageImportExportWindowState, + dispatch: (action: any) => void, +}) { + return <> + + + + + + Import / Export + + + An exported database contains all your artists, albums, tracks and tags.
+ It is represented as a JSON structure. +
+ + Upon importing a previously exported database, your database will be completely replaced! + + + + + + + +} \ No newline at end of file diff --git a/client/src/components/windows/manage_links/BatchLinkDialog.tsx b/client/src/components/windows/manage_links/BatchLinkDialog.tsx index ba9e8dd..0184e90 100644 --- a/client/src/components/windows/manage_links/BatchLinkDialog.tsx +++ b/client/src/components/windows/manage_links/BatchLinkDialog.tsx @@ -171,7 +171,7 @@ async function doLinking( [ResourceType.Artist]: getArtist, } 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.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`, [ResourceType.Album]: (s: any) => `${s.name}` + diff --git a/client/src/components/windows/query/QueryWindow.tsx b/client/src/components/windows/query/QueryWindow.tsx index 779fa78..7f3db2e 100644 --- a/client/src/components/windows/query/QueryWindow.tsx +++ b/client/src/components/windows/query/QueryWindow.tsx @@ -62,7 +62,7 @@ async function getTrackNames(filter: string) { 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 { diff --git a/client/src/lib/trackGetters.tsx b/client/src/lib/trackGetters.tsx index 6582f14..1c4a709 100644 --- a/client/src/lib/trackGetters.tsx +++ b/client/src/lib/trackGetters.tsx @@ -1,10 +1,10 @@ export const trackGetters = { - getTitle: (track: any) => track.title, + getName: (track: any) => track.name, getId: (track: any) => track.trackId, getArtistNames: (track: any) => track.artists.map((a: any) => a.name), getArtistIds: (track: any) => track.artists.map((a: any) => a.artistId), - getAlbumNames: (track: any) => track.albums.map((a: any) => a.name), - getAlbumIds: (track: any) => track.albums.map((a: any) => a.albumId), + getAlbumName: (track: any) => track.album ? track.album.name : undefined, + getAlbumId: (track: any) => track.album ? track.album.albumId : undefined, getTagNames: (track: any) => { // Recursively resolve the name. const resolveTag = (tag: any) => { diff --git a/client/src/setupProxy.js b/client/src/setupProxy.js new file mode 100644 index 0000000..1e39e75 --- /dev/null +++ b/client/src/setupProxy.js @@ -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, + }) + ); +}; \ No newline at end of file diff --git a/scripts/gpm_retrieve/gpm_retrieve.py b/scripts/gpm_retrieve/gpm_retrieve.py index 7efa4e9..97828d3 100755 --- a/scripts/gpm_retrieve/gpm_retrieve.py +++ b/scripts/gpm_retrieve/gpm_retrieve.py @@ -13,7 +13,7 @@ creds_path = sys.path[0] + '/mobileclient.cred' def authenticate(api): 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. s = requests.Session() 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.") # Helpers - def getArtistStoreIds(song): - if 'artistId' in song: - return [song['artistId'][0]] + def getArtistStoreIds(track): + if 'artistId' in track: + return [track['artistId'][0]] return [] - def getSongStoreIds(song): - if 'storeId' in song: - return [song['storeId']] + def getTrackStoreIds(track): + if 'storeId' in track: + return [track['storeId']] return [] # Create GPM import tag - gpmTagIdResponse = s.post(mudbase_api + '/tag', data={ - 'name': 'GPM Import' + gpmTagIdResponse = s.post(mudbase_api + '/tag', json={ + 'name': 'GPM Import', + 'mbApi_typename': 'tag', + 'parentId': None, }).json() gpmTagId = gpmTagIdResponse['id'] print(f"Created tag \"GPM Import\", response: {gpmTagIdResponse}") # Create the root genre tag - genreRootResponse = s.post(mudbase_api + '/tag', data={ - 'name': 'Genre' + genreRootResponse = s.post(mudbase_api + '/tag', json={ + 'name': 'Genre', + 'mbApi_typename': 'tag', + 'parentId': None, }).json() genreRootTagId = genreRootResponse['id'] print(f"Created tag \"Genre\", response: {genreRootResponse}") @@ -54,27 +58,16 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs): storedAlbums = dict() storedGenreTags = dict() - for song in songs: + for track in tracks: # TODO: check if these items already exist # Determine artist properties. artist = { - 'name': song['artist'], - 'storeLinks': ['https://play.google.com/music/m' + id for id in getArtistStoreIds(song)], + 'mbApi_typename': 'artist', + 'name': track['artist'], + 'storeLinks': ['https://play.google.com/music/m' + id for id in getArtistStoreIds(track)], 'tagIds': [gpmTagId] - } if 'artist' in song 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 + } if 'artist' in track else None # Upload artist if not already done artistId = None @@ -90,6 +83,21 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs): f"Created artist \"{artist['name']}\", response: {response}") 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 albumId = None if album: @@ -118,44 +126,45 @@ def uploadLibrary(mudbase_api, mudbase_user, mudbase_password, songs): f"Created genre tag \"Genre / {genre['name']}\", response: {response}") storedGenreTags[genreTagId] = genre - # Upload the song itself + # Upload the track itself tagIds = [gpmTagId] if genreTagId: tagIds.append(genreTagId) - _song = { - 'title': song['title'], + _track = { + 'mbApi_typename': 'track', + 'name': track['title'], 'artistIds': [artistId] if artistId != None else [], - 'albumIds': [albumId] if albumId != None else [], + 'albumId': albumId if albumId else None, '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( - 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): return { - "songs": api.get_all_songs(), + "tracks": api.get_all_tracks(), "playlists": api.get_all_user_playlist_contents() } -def getSongs(data): - # Get songs from library - songs = [] # data['songs'] +def getTracks(data): + # Get tracks from library + tracks = [] # data['tracks'] - # Append songs from playlists + # Append tracks from playlists for playlist in data['playlists']: for track in playlist['tracks']: 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. - def sI(song): return song['artist'] + '-' + \ - song['title'] if 'artist' in song and 'title' in song else 'z' - return list(dict((sI(song), song) for song in songs).values()) + def sI(track): return track['artist'] + '-' + \ + track['title'] if 'artist' in track and 'title' in track else 'z' + return list(dict((sI(track), track) for track in tracks).values()) api = Mobileclient() @@ -182,7 +191,7 @@ if args.authenticate: 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): api.oauth_login(Mobileclient.FROM_MAC_ADDRESS, oauth_credentials=creds_path) @@ -198,11 +207,11 @@ if args.load_from: with open(args.load_from, 'r') as f: data = json.load(f) -songs = getSongs(data) -print(f"Found {len(songs)} songs.") +tracks = getTracks(data) +print(f"Found {len(tracks)} tracks.") if args.mudbase_api: api.oauth_login(Mobileclient.FROM_MAC_ADDRESS, oauth_credentials=creds_path) uploadLibrary(args.mudbase_api, args.mudbase_user, - args.mudbase_password, songs) + args.mudbase_password, tracks) diff --git a/server/app.ts b/server/app.ts index 0e4bba6..ddc3d2a 100644 --- a/server/app.ts +++ b/server/app.ts @@ -2,6 +2,7 @@ const bodyParser = require('body-parser'); import * as api from '../client/src/api/api'; import Knex from 'knex'; +import { importExportEndpoints } from './endpoints/ImportExport'; import { queryEndpoints } from './endpoints/Query'; import { artistEndpoints } from './endpoints/Artist'; import { albumEndpoints } from './endpoints/Album'; @@ -120,6 +121,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { integrationEndpoints, userEndpoints, queryEndpoints, + importExportEndpoints, ].forEach((endpoints: [string, string, boolean, endpointTypes.EndpointHandler][]) => { endpoints.forEach((endpoint: [string, string, boolean, endpointTypes.EndpointHandler]) => { let [url, method, authenticated, handler] = endpoint; diff --git a/server/db/Album.ts b/server/db/Album.ts index 7c1cd69..358f7b0 100644 --- a/server/db/Album.ts +++ b/server/db/Album.ts @@ -3,6 +3,7 @@ import { AlbumBaseWithRefs, AlbumWithDetails, AlbumWithRefs } from "../../client import * as api from '../../client/src/api/api'; import asJson from "../lib/asJson"; import { DBError, DBErrorKind } from "../endpoints/types"; +var _ = require('lodash'); // Returns an album with details, or null if not found. export async function getAlbum(id: number, userId: number, knex: Knex): @@ -75,272 +76,260 @@ export async function getAlbum(id: number, userId: number, knex: Knex): // Returns the id of the created album. export async function createAlbum(userId: number, album: AlbumWithRefs, knex: Knex): Promise { return await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise: Promise = - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', album.artistIds) - .then((as: any) => as.map((a: any) => a['id'])); + // Start retrieving artists. + const artistIdsPromise: Promise = + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', album.artistIds || []) + .then((as: any) => as.map((a: any) => a['id'])); - // Start retrieving tags. - const tagIdsPromise: Promise = - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', album.tagIds) - .then((as: any) => as.map((a: any) => a['id'])); + // Start retrieving tags. + const tagIdsPromise: Promise = + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', album.tagIds || []) + .then((as: any) => as.map((a: any) => a['id'])); - // Start retrieving tracks. - const trackIdsPromise: Promise = - trx.select('id') - .from('tracks') - .where({ 'user': userId }) - .whereIn('id', album.trackIds) - .then((as: any) => as.map((a: any) => a['id'])); - - // Wait for the requests to finish. - var [artists, tags, tracks] = await Promise.all([artistIdsPromise, tagIdsPromise, trackIdsPromise]);; - - // Check that we found all artists and tags we need. - if ((new Set(artists.map((a: any) => a['id'])) !== new Set(album.artistIds)) || - (new Set(tags.map((a: any) => a['id'])) !== new Set(album.tagIds)) || - (new Set(tracks.map((a: any) => a['id'])) !== new Set(album.trackIds))) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Create the album. - const albumId = (await trx('albums') - .insert({ - name: album.name, - storeLinks: JSON.stringify(album.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the artists via the linking table. - if (artists && artists.length) { - await trx('artists_albums').insert( - artists.map((artistId: number) => { - return { - artistId: artistId, - albumId: albumId, - } - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('albums_tags').insert( - tags.map((tagId: number) => { - return { - albumId: albumId, - tagId: tagId, - } - }) - ) - } - - // Link the tracks via the linking table. - if (tracks && tracks.length) { - await trx('tracks_albums').insert( - tracks.map((trackId: number) => { - return { - albumId: albumId, - trackId: trackId, - } - }) - ) - } - - return albumId; - - } catch (e) { - trx.rollback(); + // Start retrieving tracks. + const trackIdsPromise: Promise = + trx.select('id') + .from('tracks') + .where({ 'user': userId }) + .whereIn('id', album.trackIds || []) + .then((as: any) => as.map((a: any) => a['id'])); + + // Wait for the requests to finish. + var [artists, tags, tracks] = await Promise.all([artistIdsPromise, tagIdsPromise, trackIdsPromise]);; + + // Check that we found all artists and tags we need. + if ((!_.isEqual(artists.sort(), (album.artistIds || []).sort())) || + (!_.isEqual(tags.sort(), (album.tagIds || []).sort())) || + (!_.isEqual(tracks.sort(), (album.trackIds || []).sort()))) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Create the album. + const albumId = (await trx('albums') + .insert({ + name: album.name, + storeLinks: JSON.stringify(album.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the artists via the linking table. + if (artists && artists.length) { + await trx('artists_albums').insert( + artists.map((artistId: number) => { + return { + artistId: artistId, + albumId: albumId, + } + }) + ) + } + + // Link the tags via the linking table. + if (tags && tags.length) { + await trx('albums_tags').insert( + tags.map((tagId: number) => { + return { + albumId: albumId, + tagId: tagId, + } + }) + ) + } + + // Link the tracks via the linking table. + if (tracks && tracks.length) { + await trx('tracks_albums').insert( + tracks.map((trackId: number) => { + return { + albumId: albumId, + trackId: trackId, + } + }) + ) + } + + return albumId; }) } export async function modifyAlbum(userId: number, albumId: number, album: AlbumBaseWithRefs, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start retrieving the album itself. - const albumIdPromise: Promise = - trx.select('id') - .from('albums') - .where({ 'user': userId }) - .where({ id: albumId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - // Start retrieving artists if we are modifying those. - const artistIdsPromise: Promise = - album.artistIds ? - trx.select('artistId') - .from('artists_albums') - .whereIn('artistId', album.artistIds) - .then((as: any) => as.map((a: any) => a['artistId'])) - : (async () => undefined)(); - - // Start retrieving tracks if we are modifying those. - const trackIdsPromise: Promise = - album.trackIds ? - trx.select('artistId') - .from('tracks_albums') - .whereIn('albumId', album.trackIds) - .then((as: any) => as.map((a: any) => a['trackId'])) - : (async () => undefined)(); - - // Start retrieving tags if we are modifying those. - const tagIdsPromise = - album.tagIds ? - trx.select('id') - .from('albums_tags') - .whereIn('tagId', album.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => undefined)(); - - // Wait for the requests to finish. - var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);; - - // Check that we found all objects we need. - if ((!artists || new Set(artists.map((a: any) => a['id'])) !== new Set(album.artistIds)) || - (!tags || new Set(tags.map((a: any) => a['id'])) !== new Set(album.tagIds)) || - (!tracks || new Set(tracks.map((a: any) => a['id'])) !== new Set(album.trackIds)) || - !oldAlbum) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Modify the album. - var update: any = {}; - if ("name" in album) { update["name"] = album.name; } - if ("storeLinks" in album) { update["storeLinks"] = JSON.stringify(album.storeLinks || []); } - - const modifyAlbumPromise = trx('albums') + // Start retrieving the album itself. + const albumIdPromise: Promise = + trx.select('id') + .from('albums') .where({ 'user': userId }) - .where({ 'id': albumId }) - .update(update) - - // Remove unlinked artists. - const removeUnlinkedArtists = artists ? trx('artists_albums') - .where({ 'albumId': albumId }) - .whereNotIn('artistId', album.artistIds || []) - .delete() : undefined; - - // Remove unlinked tags. - const removeUnlinkedTags = tags ? trx('albums_tags') - .where({ 'albumId': albumId }) - .whereNotIn('tagId', album.tagIds || []) - .delete() : undefined; - - // Remove unlinked tracks. - const removeUnlinkedTracks = tracks ? trx('tracks_albums') - .where({ 'albumId': albumId }) - .whereNotIn('trackId', album.trackIds || []) - .delete() : undefined; - - // Link new artists. - const addArtists = artists ? trx('artists_albums') - .where({ 'albumId': albumId }) - .then((as: any) => as.map((a: any) => a['artistId'])) - .then((doneArtistIds: number[]) => { - // Get the set of artists that are not yet linked - const toLink = (artists || []).filter((id: number) => { - return !doneArtistIds.includes(id); - }); - const insertObjects = toLink.map((artistId: number) => { - return { - artistId: artistId, - albumId: albumId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('artists_albums').insert(obj) - ) - ); - }) : undefined; - - // Link new tracks. - const addTracks = tracks ? trx('tracks_albums') - .where({ 'albumId': albumId }) - .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 { - trackId: trackId, - albumId: albumId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('tracks_albums').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - const addTags = tags ? trx('albums_tags') - .where({ 'albumId': albumId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) - .then((doneTagIds: number[]) => { - // Get the set of tags that are not yet linked - const toLink = tags.filter((id: number) => { - return !doneTagIds.includes(id); - }); - const insertObjects = toLink.map((tagId: number) => { - return { - tagId: tagId, - albumId: albumId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('albums_tags').insert(obj) - ) - ); - }) : undefined; - - // Wait for all operations to finish. - await Promise.all([ - modifyAlbumPromise, - removeUnlinkedArtists, - removeUnlinkedTags, - removeUnlinkedTracks, - addArtists, - addTags, - addTracks, - ]); - - return; - - } catch (e) { - trx.rollback(); + .where({ id: albumId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + // Start retrieving artists if we are modifying those. + const artistIdsPromise: Promise = + album.artistIds ? + trx.select('artistId') + .from('artists_albums') + .whereIn('artistId', album.artistIds) + .then((as: any) => as.map((a: any) => a['artistId'])) + : (async () => undefined)(); + + // Start retrieving tracks if we are modifying those. + const trackIdsPromise: Promise = + album.trackIds ? + trx.select('artistId') + .from('tracks_albums') + .whereIn('albumId', album.trackIds) + .then((as: any) => as.map((a: any) => a['trackId'])) + : (async () => undefined)(); + + // Start retrieving tags if we are modifying those. + const tagIdsPromise = + album.tagIds ? + trx.select('id') + .from('albums_tags') + .whereIn('tagId', album.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => undefined)(); + + // Wait for the requests to finish. + var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);; + + // Check that we found all objects we need. + if ((!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort())) || + (!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort())) || + (!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort())) || + !oldAlbum) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Modify the album. + var update: any = {}; + if ("name" in album) { update["name"] = album.name; } + if ("storeLinks" in album) { update["storeLinks"] = JSON.stringify(album.storeLinks || []); } + + const modifyAlbumPromise = trx('albums') + .where({ 'user': userId }) + .where({ 'id': albumId }) + .update(update) + + // Remove unlinked artists. + const removeUnlinkedArtists = artists ? trx('artists_albums') + .where({ 'albumId': albumId }) + .whereNotIn('artistId', album.artistIds || []) + .delete() : undefined; + + // Remove unlinked tags. + const removeUnlinkedTags = tags ? trx('albums_tags') + .where({ 'albumId': albumId }) + .whereNotIn('tagId', album.tagIds || []) + .delete() : undefined; + + // Remove unlinked tracks. + const removeUnlinkedTracks = tracks ? trx('tracks_albums') + .where({ 'albumId': albumId }) + .whereNotIn('trackId', album.trackIds || []) + .delete() : undefined; + + // Link new artists. + const addArtists = artists ? trx('artists_albums') + .where({ 'albumId': albumId }) + .then((as: any) => as.map((a: any) => a['artistId'])) + .then((doneArtistIds: number[]) => { + // Get the set of artists that are not yet linked + const toLink = (artists || []).filter((id: number) => { + return !doneArtistIds.includes(id); + }); + const insertObjects = toLink.map((artistId: number) => { + return { + artistId: artistId, + albumId: albumId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('artists_albums').insert(obj) + ) + ); + }) : undefined; + + // Link new tracks. + const addTracks = tracks ? trx('tracks_albums') + .where({ 'albumId': albumId }) + .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 { + trackId: trackId, + albumId: albumId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('tracks_albums').insert(obj) + ) + ); + }) : undefined; + + // Link new tags. + const addTags = tags ? trx('albums_tags') + .where({ 'albumId': albumId }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) + .then((doneTagIds: number[]) => { + // Get the set of tags that are not yet linked + const toLink = tags.filter((id: number) => { + return !doneTagIds.includes(id); + }); + const insertObjects = toLink.map((tagId: number) => { + return { + tagId: tagId, + albumId: albumId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('albums_tags').insert(obj) + ) + ); + }) : undefined; + + // Wait for all operations to finish. + await Promise.all([ + modifyAlbumPromise, + removeUnlinkedArtists, + removeUnlinkedTags, + removeUnlinkedTracks, + addArtists, + addTags, + addTracks, + ]); + + return; }) const e: DBError = { @@ -353,53 +342,49 @@ export async function modifyAlbum(userId: number, albumId: number, album: AlbumB export async function deleteAlbum(userId: number, albumId: number, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start by retrieving the album itself for sanity. - const confirmAlbumId: number | undefined = - await trx.select('id') - .from('albums') - .where({ 'user': userId }) - .where({ id: albumId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - if (!confirmAlbumId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all resources were found.', - }; - throw e; - } - - // Start deleting artist associations with the album. - const deleteArtistsPromise: Promise = - trx.delete() - .from('artists_albums') - .where({ 'albumId': albumId }); - // Start deleting tag associations with the album. - const deleteTagsPromise: Promise = - trx.delete() - .from('albums_tags') - .where({ 'albumId': albumId }); - - // Start deleting track associations with the album. - const deleteTracksPromise: Promise = - trx.delete() - .from('tracks_albums') - .where({ 'albumId': albumId }); - - // Start deleting the album. - const deleteAlbumPromise: Promise = - trx.delete() - .from('albums') - .where({ id: albumId }); - - // Wait for the requests to finish. - await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTracksPromise, deleteAlbumPromise]); - } catch (e) { - trx.rollback(); + // Start by retrieving the album itself for sanity. + const confirmAlbumId: number | undefined = + await trx.select('id') + .from('albums') + .where({ 'user': userId }) + .where({ id: albumId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + if (!confirmAlbumId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; throw e; } + + // Start deleting artist associations with the album. + const deleteArtistsPromise: Promise = + trx.delete() + .from('artists_albums') + .where({ 'albumId': albumId }); + + // Start deleting tag associations with the album. + const deleteTagsPromise: Promise = + trx.delete() + .from('albums_tags') + .where({ 'albumId': albumId }); + + // Start deleting track associations with the album. + const deleteTracksPromise: Promise = + trx.delete() + .from('tracks_albums') + .where({ 'albumId': albumId }); + + // Start deleting the album. + const deleteAlbumPromise: Promise = + trx.delete() + .from('albums') + .where({ id: albumId }); + + // Wait for the requests to finish. + await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTracksPromise, deleteAlbumPromise]); }) } \ No newline at end of file diff --git a/server/db/Artist.ts b/server/db/Artist.ts index 94a1ac6..92c95cb 100644 --- a/server/db/Artist.ts +++ b/server/db/Artist.ts @@ -3,6 +3,7 @@ import { ArtistBaseWithRefs, ArtistWithDetails, ArtistWithRefs } from "../../cli import * as api from '../../client/src/api/api'; import asJson from "../lib/asJson"; import { DBError, DBErrorKind } from "../endpoints/types"; +var _ = require('lodash') // Returns an artist with details, or null if not found. 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((ids: number[]) => knex.select(['id', 'name', 'storeLinks']) - .from('album') + .from('albums') + .whereIn('id', ids) + ); + + const tracksPromise: Promise = + 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) ); @@ -39,8 +51,8 @@ export async function getArtist(id: number, userId: number, knex: Knex): .then((artists: any) => artists[0]); // Wait for the requests to finish. - const [artist, tags, albums] = - await Promise.all([artistPromise, tagsPromise, albumsPromise]); + const [artist, tags, albums, tracks] = + await Promise.all([artistPromise, tagsPromise, albumsPromise, tracksPromise]); if (artist) { return { @@ -48,6 +60,7 @@ export async function getArtist(id: number, userId: number, knex: Knex): name: artist['name'], albums: albums as api.AlbumWithId[], tags: tags as api.TagWithId[], + tracks: tracks as api.TrackWithId[], storeLinks: asJson(artist['storeLinks'] || []), }; } @@ -63,261 +76,307 @@ export async function getArtist(id: number, userId: number, knex: Knex): // Returns the id of the created artist. export async function createArtist(userId: number, artist: ArtistWithRefs, knex: Knex): Promise { return await knex.transaction(async (trx) => { - try { - // Start retrieving albums. - const albumIdsPromise: Promise = - trx.select('id') - .from('albums') - .where({ 'user': userId }) - .whereIn('id', artist.albumIds) - .then((as: any) => as.map((a: any) => a['id'])); + // Start retrieving albums. + const albumIdsPromise: Promise = + trx.select('id') + .from('albums') + .where({ 'user': userId }) + .whereIn('id', artist.albumIds || []) + .then((as: any) => as.map((a: any) => a['id'])); - // Start retrieving tags. - const tagIdsPromise: Promise = - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', artist.tagIds) - .then((as: any) => as.map((a: any) => a['id'])); - - // Wait for the requests to finish. - var [albums, tags] = await Promise.all([albumIdsPromise, tagIdsPromise]);; - - // Check that we found all artists and tags we need. - if ((new Set(albums.map((a: any) => a['id'])) !== new Set(artist.albumIds)) || - (new Set(tags.map((a: any) => a['id'])) !== new Set(artist.tagIds))) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Create the artist. - const artistId = (await trx('artists') - .insert({ - name: artist.name, - storeLinks: JSON.stringify(artist.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the albums via the linking table. - if (albums && albums.length) { - await trx('artists_albums').insert( - albums.map((albumId: number) => { - return { - albumId: albumId, - artistId: artistId, - } - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('artists_tags').insert( - tags.map((tagId: number) => { - return { - artistId: artistId, - tagId: tagId, - } - }) - ) - } - - return artistId; - - } catch (e) { - trx.rollback(); + // Start retrieving tracks. + const trackIdsPromise: Promise = + trx.select('id') + .from('tracks') + .where({ 'user': userId }) + .whereIn('id', artist.trackIds || []) + .then((as: any) => as.map((a: any) => a['id'])); + + // Start retrieving tags. + const tagIdsPromise: Promise = + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', artist.tagIds || []) + .then((as: any) => as.map((a: any) => a['id'])); + + // Wait for the requests to finish. + var [albums, tags, tracks] = await Promise.all([albumIdsPromise, tagIdsPromise, trackIdsPromise]);; + + // Check that we found all artists and tags we need. + if (!_.isEqual(albums.sort(), (artist.albumIds || []).sort()) || + !_.isEqual(tags.sort(), (artist.tagIds || []).sort()) || + !_.isEqual(tracks.sort(), (artist.trackIds || []).sort())) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Create the artist. + const artistId = (await trx('artists') + .insert({ + name: artist.name, + storeLinks: JSON.stringify(artist.storeLinks || []), + user: userId, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the albums via the linking table. + if (albums && albums.length) { + await trx('artists_albums').insert( + albums.map((albumId: number) => { + return { + albumId: albumId, + artistId: artistId, + } + }) + ) + } + + // 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. + if (tags && tags.length) { + await trx('artists_tags').insert( + tags.map((tagId: number) => { + return { + artistId: artistId, + tagId: tagId, + } + }) + ) + } + + return artistId; }) } export async function modifyArtist(userId: number, artistId: number, artist: ArtistBaseWithRefs, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start retrieving the artist itself. - const artistIdPromise: Promise = - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .where({ id: artistId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - // Start retrieving albums if we are modifying those. - const albumIdsPromise: Promise = - artist.albumIds ? - trx.select('albumId') - .from('artists_albums') - .whereIn('id', artist.albumIds) - .then((as: any) => as.map((a: any) => a['albumId'])) - : (async () => undefined)(); - - // Start retrieving tags if we are modifying those. - const tagIdsPromise = - artist.tagIds ? - trx.select('id') - .from('artists_tags') - .whereIn('id', artist.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => undefined)(); - - // Wait for the requests to finish. - var [oldArtist, albums, tags] = await Promise.all([artistIdPromise, albumIdsPromise, tagIdsPromise]);; - - // Check that we found all objects we need. - if ((!albums || new Set(albums.map((a: any) => a['id'])) !== new Set(artist.albumIds)) || - (!tags || new Set(tags.map((a: any) => a['id'])) !== new Set(artist.tagIds)) || - !oldArtist) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Modify the artist. - var update: any = {}; - if ("name" in artist) { update["name"] = artist.name; } - if ("storeLinks" in artist) { update["storeLinks"] = JSON.stringify(artist.storeLinks || []); } - - const modifyArtistPromise = trx('artists') + // Start retrieving the artist itself. + const artistIdPromise: Promise = + trx.select('id') + .from('artists') .where({ 'user': userId }) - .where({ 'id': artistId }) - .update(update) - - // Remove unlinked albums. - const removeUnlinkedAlbums = albums ? trx('artists_albums') - .where({ 'artistId': artistId }) - .whereNotIn('albumId', artist.albumIds || []) - .delete() : undefined; - - // Remove unlinked tags. - const removeUnlinkedTags = tags ? trx('artists_tags') - .where({ 'artistId': artistId }) - .whereNotIn('tagId', artist.tagIds || []) - .delete() : undefined; - - // Link new albums. - const addAlbums = albums ? trx('artists_albums') - .where({ 'artistId': artistId }) - .then((as: any) => as.map((a: any) => a['albumId'])) - .then((doneAlbumIds: number[]) => { - // Get the set of artists that are not yet linked - const toLink = (albums || []).filter((id: number) => { - return !doneAlbumIds.includes(id); - }); - const insertObjects = toLink.map((albumId: number) => { - return { - artistId: artistId, - albumId: albumId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('artists_artists').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - const addTags = tags ? trx('artists_tags') - .where({ 'artistId': artistId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) - .then((doneTagIds: number[]) => { - // Get the set of tags that are not yet linked - const toLink = tags.filter((id: number) => { - return !doneTagIds.includes(id); - }); - const insertObjects = toLink.map((tagId: number) => { - return { - tagId: tagId, - artistId: artistId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('artists_tags').insert(obj) - ) - ); - }) : undefined; - - // Wait for all operations to finish. - await Promise.all([ - modifyArtistPromise, - removeUnlinkedAlbums, - removeUnlinkedTags, - addAlbums, - addTags - ]); - - return; - - } catch (e) { - trx.rollback(); + .where({ id: artistId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + // Start retrieving albums if we are modifying those. + const albumIdsPromise: Promise = + artist.albumIds ? + trx.select('albumId') + .from('artists_albums') + .whereIn('id', artist.albumIds) + .then((as: any) => as.map((a: any) => a['albumId'])) + : (async () => undefined)(); + + // Start retrieving tracks if we are modifying those. + const trackIdsPromise: Promise = + 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. + const tagIdsPromise = + artist.tagIds ? + trx.select('id') + .from('artists_tags') + .whereIn('id', artist.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => undefined)(); + + // Wait for the requests to finish. + var [oldArtist, albums, tags, tracks] = await Promise.all([artistIdPromise, albumIdsPromise, tagIdsPromise, trackIdsPromise]);; + + // Check that we found all objects we need. + if ((!albums || !_.isEqual(albums.sort(), (artist.albumIds || []).sort())) || + (!tags || !_.isEqual(tags.sort(), (artist.tagIds || []).sort())) || + (!tracks || !_.isEqual(tracks.sort(), (artist.trackIds || []).sort())) || + !oldArtist) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Modify the artist. + var update: any = {}; + if ("name" in artist) { update["name"] = artist.name; } + if ("storeLinks" in artist) { update["storeLinks"] = JSON.stringify(artist.storeLinks || []); } + + const modifyArtistPromise = trx('artists') + .where({ 'user': userId }) + .where({ 'id': artistId }) + .update(update) + + // Remove unlinked albums. + const removeUnlinkedAlbums = albums ? trx('artists_albums') + .where({ 'artistId': artistId }) + .whereNotIn('albumId', artist.albumIds || []) + .delete() : undefined; + + // Remove unlinked tracks. + const removeUnlinkedTracks = tracks ? trx('tracks_artists') + .where({ 'artistId': artistId }) + .whereNotIn('trackId', artist.trackIds || []) + .delete() : undefined; + + // Remove unlinked tags. + const removeUnlinkedTags = tags ? trx('artists_tags') + .where({ 'artistId': artistId }) + .whereNotIn('tagId', artist.tagIds || []) + .delete() : undefined; + + // Link new albums. + const addAlbums = albums ? trx('artists_albums') + .where({ 'artistId': artistId }) + .then((as: any) => as.map((a: any) => a['albumId'])) + .then((doneAlbumIds: number[]) => { + // Get the set of artists that are not yet linked + const toLink = (albums || []).filter((id: number) => { + return !doneAlbumIds.includes(id); + }); + const insertObjects = toLink.map((albumId: number) => { + return { + artistId: artistId, + albumId: albumId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + 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; + + // Link new tags. + const addTags = tags ? trx('artists_tags') + .where({ 'artistId': artistId }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) + .then((doneTagIds: number[]) => { + // Get the set of tags that are not yet linked + const toLink = tags.filter((id: number) => { + return !doneTagIds.includes(id); + }); + const insertObjects = toLink.map((tagId: number) => { + return { + tagId: tagId, + artistId: artistId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('artists_tags').insert(obj) + ) + ); + }) : undefined; + + // Wait for all operations to finish. + await Promise.all([ + modifyArtistPromise, + removeUnlinkedAlbums, + removeUnlinkedTags, + removeUnlinkedTracks, + addAlbums, + addTags, + addTracks, + ]); + + return; }) } export async function deleteArtist(userId: number, artistId: number, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start by retrieving the artist itself for sanity. - const confirmArtistId: number | undefined = - await trx.select('id') - .from('artists') - .where({ 'user': userId }) - .where({ id: artistId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - if (!confirmArtistId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all resources were found.', - }; - throw e; - } - - // Start deleting artist associations with the artist. - const deleteAlbumsPromise: Promise = - trx.delete() - .from('artists_albums') - .where({ 'artistId': artistId }); - - // Start deleting tag associations with the artist. - const deleteTagsPromise: Promise = - trx.delete() - .from('artists_tags') - .where({ 'artistId': artistId }); - - // Start deleting track associations with the artist. - const deleteTracksPromise: Promise = - trx.delete() - .from('tracks_artists') - .where({ 'artistId': artistId }); - - // Start deleting the artist. - const deleteArtistPromise: Promise = - trx.delete() - .from('artists') - .where({ id: artistId }); - - // Wait for the requests to finish. - await Promise.all([deleteAlbumsPromise, deleteTagsPromise, deleteTracksPromise, deleteArtistPromise]); - } catch (e) { - trx.rollback(); + // Start by retrieving the artist itself for sanity. + const confirmArtistId: number | undefined = + await trx.select('id') + .from('artists') + .where({ 'user': userId }) + .where({ id: artistId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + if (!confirmArtistId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; throw e; } + + // Start deleting artist associations with the artist. + const deleteAlbumsPromise: Promise = + trx.delete() + .from('artists_albums') + .where({ 'artistId': artistId }); + + // Start deleting tag associations with the artist. + const deleteTagsPromise: Promise = + trx.delete() + .from('artists_tags') + .where({ 'artistId': artistId }); + + // Start deleting track associations with the artist. + const deleteTracksPromise: Promise = + trx.delete() + .from('tracks_artists') + .where({ 'artistId': artistId }); + + // Start deleting the artist. + const deleteArtistPromise: Promise = + trx.delete() + .from('artists') + .where({ id: artistId }); + + // Wait for the requests to finish. + await Promise.all([deleteAlbumsPromise, deleteTagsPromise, deleteTracksPromise, deleteArtistPromise]); }) } \ No newline at end of file diff --git a/server/db/ImportExport.ts b/server/db/ImportExport.ts index cb1f5b9..f9440f7 100644 --- a/server/db/ImportExport.ts +++ b/server/db/ImportExport.ts @@ -1,5 +1,5 @@ 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 asJson from "../lib/asJson"; import { createArtist } from "./Artist"; @@ -7,26 +7,12 @@ import { createTag } from "./Tag"; import { createAlbum } from "./Album"; import { createTrack } from "./Track"; -// 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 -// generated. -interface DBImportExportFormat { - tracks: TrackWithRefsWithId[], - albums: AlbumWithRefsWithId[], - artists: ArtistWithRefsWithId[], - tags: TagWithRefsWithId[], -} - -export async function exportDB(userId: number, knex: Knex): Promise { +export async function exportDB(userId: number, knex: Knex): Promise { // First, retrieve all the objects without taking linking tables into account. // Fetch the links separately. let tracksPromise: Promise = - knex.select('id', 'name', 'storeLinks', 'albumId') + knex.select('id', 'name', 'storeLinks', 'album') .from('tracks') .where({ 'user': userId }) .then((ts: any[]) => ts.map((t: any) => { @@ -35,7 +21,7 @@ export async function exportDB(userId: number, knex: Knex): Promise { let [trackId, artistId] = v; 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]) => { let [trackId, tagId] = v; @@ -164,41 +154,37 @@ export async function exportDB(userId: number, knex: Knex): Promise { +export async function importDB(userId: number, db: api.DBImportExportFormat, knex: Knex): Promise { return await knex.transaction(async (trx) => { // Store the ID mappings in this record. let tagIdMaps: Record = {}; let artistIdMaps: Record = {}; let albumIdMaps: Record = {}; let trackIdMaps: Record = {}; - try { - // Insert items one by one, remapping the IDs as we go. - await Promise.all(db.tags.map((tag: TagWithRefsWithId) => async () => { - tagIdMaps[tag.id] = await createTag(userId, tag, knex); - })); - await Promise.all(db.artists.map((artist: ArtistWithRefsWithId) => async () => { - artistIdMaps[artist.id] = await createArtist(userId, { - ...artist, - tagIds: artist.tagIds.map((id: number) => tagIdMaps[id]), - }, knex); - })) - await Promise.all(db.albums.map((album: AlbumWithRefsWithId) => async () => { - albumIdMaps[album.id] = await createAlbum(userId, { - ...album, - tagIds: album.tagIds.map((id: number) => tagIdMaps[id]), - artistIds: album.artistIds.map((id: number) => artistIdMaps[id]), - }, knex); - })) - await Promise.all(db.tracks.map((track: TrackWithRefsWithId) => async () => { - trackIdMaps[track.id] = await createTrack(userId, { - ...track, - tagIds: track.tagIds.map((id: number) => tagIdMaps[id]), - artistIds: track.artistIds.map((id: number) => artistIdMaps[id]), - albumId: track.albumId ? albumIdMaps[track.albumId] : null, - }, knex); - })) - } catch (e) { - trx.rollback(); - } + // Insert items one by one, remapping the IDs as we go. + await Promise.all(db.tags.map((tag: TagWithRefsWithId) => async () => { + tagIdMaps[tag.id] = await createTag(userId, tag, knex); + })); + await Promise.all(db.artists.map((artist: ArtistWithRefsWithId) => async () => { + artistIdMaps[artist.id] = await createArtist(userId, { + ...artist, + tagIds: artist.tagIds.map((id: number) => tagIdMaps[id]), + }, knex); + })) + await Promise.all(db.albums.map((album: AlbumWithRefsWithId) => async () => { + albumIdMaps[album.id] = await createAlbum(userId, { + ...album, + tagIds: album.tagIds.map((id: number) => tagIdMaps[id]), + artistIds: album.artistIds.map((id: number) => artistIdMaps[id]), + }, knex); + })) + await Promise.all(db.tracks.map((track: TrackWithRefsWithId) => async () => { + trackIdMaps[track.id] = await createTrack(userId, { + ...track, + tagIds: track.tagIds.map((id: number) => tagIdMaps[id]), + artistIds: track.artistIds.map((id: number) => artistIdMaps[id]), + albumId: track.albumId ? albumIdMaps[track.albumId] : null, + }, knex); + })) }); } \ No newline at end of file diff --git a/server/db/Integration.ts b/server/db/Integration.ts index 0d13e9f..f5a426d 100644 --- a/server/db/Integration.ts +++ b/server/db/Integration.ts @@ -6,25 +6,20 @@ import { IntegrationDataWithId, IntegrationDataWithSecret, PartialIntegrationDat export async function createIntegration(userId: number, integration: api.IntegrationDataWithSecret, knex: Knex): Promise { return await knex.transaction(async (trx) => { - try { - // Create the new integration. - var integration: any = { - name: integration.name, - user: userId, - type: integration.type, - details: JSON.stringify(integration.details), - secretDetails: JSON.stringify(integration.secretDetails), - } - const integrationId = (await trx('integrations') - .insert(integration) - .returning('id') // Needed for Postgres - )[0]; - - return integrationId; - } catch (e) { - trx.rollback(); - throw e; + // Create the new integration. + var integration: any = { + name: integration.name, + user: userId, + type: integration.type, + details: JSON.stringify(integration.details), + secretDetails: JSON.stringify(integration.secretDetails), } + const integrationId = (await trx('integrations') + .insert(integration) + .returning('id') // Needed for Postgres + )[0]; + + return integrationId; }) } @@ -71,65 +66,56 @@ export async function listIntegrations(userId: number, knex: Knex): Promise { - try { - // Start retrieving the integration itself. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .where({ id: id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: "Resource not found." - }; - throw e; - } - - // Delete the integration. - await trx('integrations') - .where({ 'user': userId, 'id': integrationId }) - .del(); + // Start retrieving the integration itself. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .where({ id: id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - } catch (e) { - trx.rollback(); + // Check that we found all objects we need. + if (!integrationId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: "Resource not found." + }; + throw e; } + + // Delete the integration. + await trx('integrations') + .where({ 'user': userId, 'id': integrationId }) + .del(); }) } export async function modifyIntegration(userId: number, id: number, integration: PartialIntegrationData, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start retrieving the integration. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: "Resource not found", - }; - throw e; - } + // Start retrieving the integration. + const integrationId = await trx.select('id') + .from('integrations') + .where({ 'user': userId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - // Modify the integration. - var update: any = {}; - if ("name" in integration) { update["name"] = integration.name; } - if ("details" in integration) { update["details"] = JSON.stringify(integration.details); } - if ("type" in integration) { update["type"] = integration.type; } - if ("secretDetails" in integration) { update["secretDetails"] = JSON.stringify(integration.details); } - await trx('integrations') - .where({ 'user': userId, 'id': id }) - .update(update) - } catch (e) { - trx.rollback(); + // Check that we found all objects we need. + if (!integrationId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: "Resource not found", + }; + throw e; } + + // Modify the integration. + var update: any = {}; + if ("name" in integration) { update["name"] = integration.name; } + if ("details" in integration) { update["details"] = JSON.stringify(integration.details); } + if ("type" in integration) { update["type"] = integration.type; } + if ("secretDetails" in integration) { update["secretDetails"] = JSON.stringify(integration.details); } + await trx('integrations') + .where({ 'user': userId, 'id': id }) + .update(update) }) } \ No newline at end of file diff --git a/server/db/Query.ts b/server/db/Query.ts index 69120c3..c7ad76c 100644 --- a/server/db/Query.ts +++ b/server/db/Query.ts @@ -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 { mbApi_typename: "track", trackId: dbObj['tracks.id'], @@ -34,7 +34,7 @@ export function toApiTrack(dbObj: any, artists: any[], tags: any[], album: any | tags: tags.map((tag: any) => { return toApiTag(tag); }), - album: album, + album: albums.length > 0 ? toApiAlbum(albums[0]) : null, } } @@ -80,23 +80,34 @@ const objectTables: Record = { // To keep track of linking tables between objects. const linkingTables: any = [ - [[ObjectType.Track, ObjectType.Album], 'tracks_albums'], [[ObjectType.Track, ObjectType.Artist], 'tracks_artists'], [[ObjectType.Track, ObjectType.Tag], 'tracks_tags'], [[ObjectType.Artist, ObjectType.Album], 'artists_albums'], [[ObjectType.Artist, ObjectType.Tag], 'artists_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; linkingTables.forEach((row: any) => { if (row[0].includes(a) && row[0].includes(b)) { 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. @@ -124,11 +135,18 @@ function getRequiredDatabaseObjects(queryElem: api.QueryElem): Set { function addJoin(knexQuery: any, base: ObjectType, other: ObjectType) { const linkTable = getLinkingTable(base, other); + const linkColumn = getLinkingColumn(base, other); const baseTable = objectTables[base]; const otherTable = objectTables[other]; - return knexQuery - .join(linkTable, { [baseTable + '.id']: linkTable + '.' + linkingTableIdNames[base] }) - .join(otherTable, { [otherTable + '.id']: linkTable + '.' + linkingTableIdNames[other] }); + + if (linkTable) { + return knexQuery + .join(linkTable, { [baseTable + '.id']: linkTable + '.' + linkingTableIdNames[base] }) + .join(otherTable, { [otherTable + '.id']: linkTable + '.' + linkingTableIdNames[other] }); + } else if (linkColumn) { + return knexQuery + .join(otherTable, { [linkColumn]: otherTable + '.id' }); + } } enum WhereType { @@ -231,7 +249,7 @@ function getWhere(queryElem: api.QueryElem): string { } 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.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'] @@ -267,7 +285,7 @@ function constructQuery(knex: Knex, userId: number, queryFor: ObjectType, queryE // Apply ordering 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], (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[]) { var result: Record = {}; + const table = objectTables[base]; const otherTable = objectTables[linked]; - const linkingTable = getLinkingTable(base, linked); + const maybeLinkingTable = getLinkingTable(base, linked); + const maybeLinkingColumn = getLinkingColumn(base, linked); const columns = objectColumns[linked]; - await Promise.all(baseIds.map((baseId: number) => { - return knex.select(columns).groupBy(otherTable + '.id').from(otherTable) - .join(linkingTable, { [linkingTable + '.' + linkingTableIdNames[linked]]: otherTable + '.id' }) - .where({ [otherTable + '.user']: userId }) - .where({ [linkingTable + '.' + linkingTableIdNames[base]]: baseId }) - .then((others: any) => { result[baseId] = others; }) - })) + console.log(table, otherTable, maybeLinkingTable, maybeLinkingColumn); + + if (maybeLinkingTable) { + await Promise.all(baseIds.map((baseId: number) => { + return knex.select(columns).groupBy(otherTable + '.id').from(otherTable) + .join(maybeLinkingTable, { [maybeLinkingTable + '.' + linkingTableIdNames[linked]]: otherTable + '.id' }) + .where({ [otherTable + '.user']: userId }) + .where({ [maybeLinkingTable + '.' + linkingTableIdNames[base]]: baseId }) + .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); return result; @@ -344,7 +378,7 @@ export async function doQuery(userId: number, q: api.QueryRequest, knex: Knex): ObjectType.Album, q.query, q.ordering, - artistOffset || 0, + albumOffset || 0, albumLimit >= 0 ? albumLimit : null, ) : (async () => [])(); @@ -472,5 +506,6 @@ export async function doQuery(userId: number, q: api.QueryRequest, knex: Knex): } } + console.log("Query response:", response) return response; } \ No newline at end of file diff --git a/server/db/Tag.ts b/server/db/Tag.ts index 8be6127..3a8e01a 100644 --- a/server/db/Tag.ts +++ b/server/db/Tag.ts @@ -1,4 +1,5 @@ import Knex from "knex"; +import { isConstructorDeclaration } from "typescript"; import * as api from '../../client/src/api/api'; import { TagBaseWithRefs, TagWithDetails, TagWithId, TagWithRefs, TagWithRefsWithId } from "../../client/src/api/api"; import { DBError, DBErrorKind } from "../endpoints/types"; @@ -24,109 +25,98 @@ export async function getTagChildrenRecursive(id: number, userId: number, trx: a // Returns the id of the created tag. export async function createTag(userId: number, tag: TagWithRefs, knex: Knex): Promise { return await knex.transaction(async (trx) => { - try { - // If applicable, retrieve the parent tag. - const maybeParent: number | null = - tag.parentId ? - (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': tag.parentId }))[0]['id'] : - null; - - // Check if the parent was found, if applicable. - if (tag.parentId && maybeParent !== tag.parentId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Create the new tag. - var tag: any = { - name: tag.name, - user: userId, + // If applicable, retrieve the parent tag. + const maybeParent: number | null = + tag.parentId ? + (await trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'id': tag.parentId }))[0]['id'] : + null; + + // Check if the parent was found, if applicable. + if (tag.parentId && maybeParent !== tag.parentId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', }; - if (maybeParent) { - tag['parentId'] = maybeParent; - } - const tagId = (await trx('tags') - .insert(tag) - .returning('id') // Needed for Postgres - )[0]; - - return tagId; - - } catch (e) { - trx.rollback(); throw e; } + + // Create the new tag. + var newTag: any = { + name: tag.name, + user: userId, + }; + if (maybeParent) { + newTag['parentId'] = maybeParent; + } + const tagId = (await trx('tags') + .insert(newTag) + .returning('id') // Needed for Postgres + )[0]; + + return tagId; }) } export async function deleteTag(userId: number, tagId: number, knex: Knex) { await knex.transaction(async (trx) => { - try { - // Start retrieving any child tags. - const childTagsPromise = - getTagChildrenRecursive(tagId, userId, trx); + // Start retrieving any child tags. + const childTagsPromise = + getTagChildrenRecursive(tagId, userId, trx); - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: tagId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); - - // Merge all IDs. - const toDelete = [tag, ...children]; - - // Check that we found all objects we need. - if (!tag) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Start deleting artist associations with the tag. - const deleteArtistsPromise: Promise = - trx.delete() - .from('artists_tags') - .whereIn('tagId', toDelete); - - // Start deleting album associations with the tag. - const deleteAlbumsPromise: Promise = - trx.delete() - .from('albums_tags') - .whereIn('tagId', toDelete); - - // Start deleting track associations with the tag. - const deleteTracksPromise: Promise = - trx.delete() - .from('tracks_tags') - .whereIn('tagId', toDelete); - - - // Start deleting the tag and its children. - const deleteTags: Promise = trx('tags') - .where({ 'user': userId }) - .whereIn('id', toDelete) - .del(); + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: tagId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - await Promise.all([deleteArtistsPromise, deleteAlbumsPromise, deleteTracksPromise, deleteTags]) - } catch (e) { - trx.rollback(); + // Wait for the requests to finish. + var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); + + // Merge all IDs. + const toDelete = [tag, ...children]; + + // Check that we found all objects we need. + if (!tag) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Start deleting artist associations with the tag. + const deleteArtistsPromise: Promise = + trx.delete() + .from('artists_tags') + .whereIn('tagId', toDelete); + + // Start deleting album associations with the tag. + const deleteAlbumsPromise: Promise = + trx.delete() + .from('albums_tags') + .whereIn('tagId', toDelete); + + // Start deleting track associations with the tag. + const deleteTracksPromise: Promise = + trx.delete() + .from('tracks_tags') + .whereIn('tagId', toDelete); + + + // Start deleting the tag and its children. + const deleteTags: Promise = trx('tags') + .where({ 'user': userId }) + .whereIn('id', toDelete) + .del(); + + await Promise.all([deleteArtistsPromise, deleteAlbumsPromise, deleteTracksPromise, deleteTags]) }) } @@ -168,107 +158,96 @@ export async function getTag(userId: number, tagId: number, knex: Knex): Promise export async function modifyTag(userId: number, tagId: number, tag: TagBaseWithRefs, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start retrieving the parent tag. - const parentTagIdPromise: Promise = tag.parentId ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': tag.parentId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return null })(); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') + // Start retrieving the parent tag. + const parentTagIdPromise: Promise = tag.parentId ? + trx.select('id') .from('tags') .where({ 'user': userId }) - .where({ id: tagId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [dbTag, parent] = await Promise.all([tagPromise, parentTagIdPromise]); - - // Check that we found all objects we need. - if ((tag.parentId && !parent) || - !dbTag) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all resources were found.', - }; - throw e; - } - - // Modify the tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': tagId }) - .update({ - name: tag.name, - parentId: tag.parentId || null, - }) - - } catch (e) { - trx.rollback(); + .where({ 'id': tag.parentId }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return null })(); + + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: tagId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [dbTag, parent] = await Promise.all([tagPromise, parentTagIdPromise]); + + // Check that we found all objects we need. + if ((tag.parentId && !parent) || + !dbTag) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; throw e; } + + // Modify the tag. + await trx('tags') + .where({ 'user': userId }) + .where({ 'id': tagId }) + .update({ + name: tag.name, + parentId: tag.parentId || null, + }) }) } export async function mergeTag(userId: number, fromId: number, toId: number, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start retrieving the "from" tag. - const fromTagIdPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: fromId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + // Start retrieving the "from" tag. + const fromTagIdPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: fromId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - // Start retrieving the "to" tag. - const toTagIdPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: toId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [fromTagId, toTagId] = await Promise.all([fromTagIdPromise, toTagIdPromise]); - - // Check that we found all objects we need. - if (!fromTagId || !toTagId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all resources were found.', - }; - throw e; - } - - // Assign new tag ID to any objects referencing the to-be-merged tag. - const cPromise = trx('tags') - .where({ 'user': userId }) - .where({ 'parentId': fromId }) - .update({ 'parentId': toId }); - const sPromise = trx('songs_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - const arPromise = trx('artists_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - const alPromise = trx('albums_tags') - .where({ 'tagId': fromId }) - .update({ 'tagId': toId }); - await Promise.all([sPromise, arPromise, alPromise, cPromise]); - - // Delete the original tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': fromId }) - .del(); - } catch (e) { - trx.rollback(); + // Start retrieving the "to" tag. + const toTagIdPromise = trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ id: toId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [fromTagId, toTagId] = await Promise.all([fromTagIdPromise, toTagIdPromise]); + + // Check that we found all objects we need. + if (!fromTagId || !toTagId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; throw e; } + + // Assign new tag ID to any objects referencing the to-be-merged tag. + const cPromise = trx('tags') + .where({ 'user': userId }) + .where({ 'parentId': fromId }) + .update({ 'parentId': toId }); + const sPromise = trx('songs_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const arPromise = trx('artists_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const alPromise = trx('albums_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + await Promise.all([sPromise, arPromise, alPromise, cPromise]); + + // Delete the original tag. + await trx('tags') + .where({ 'user': userId }) + .where({ 'id': fromId }) + .del(); }) } \ No newline at end of file diff --git a/server/db/Track.ts b/server/db/Track.ts index e693424..bee6dff 100644 --- a/server/db/Track.ts +++ b/server/db/Track.ts @@ -3,6 +3,7 @@ import { TrackBaseWithRefs, TrackWithDetails, TrackWithRefs } from "../../client import * as api from '../../client/src/api/api'; import asJson from "../lib/asJson"; import { DBError, DBErrorKind } from "../endpoints/types"; +var _ = require('lodash') // Returns an track with details, or null if not found. export async function getTrack(id: number, userId: number, knex: Knex): @@ -76,268 +77,253 @@ export async function getTrack(id: number, userId: number, knex: Knex): // Returns the id of the created track. export async function createTrack(userId: number, track: TrackWithRefs, knex: Knex): Promise { return await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise: Promise = - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', track.artistIds) - .then((as: any) => as.map((a: any) => a['id'])); + // Start retrieving artists. + const artistIdsPromise: Promise = + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', track.artistIds) + .then((as: any) => as.map((a: any) => a['id'])); - // Start retrieving tags. - const tagIdsPromise: Promise = - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', track.tagIds) - .then((as: any) => as.map((a: any) => a['id'])); + // Start retrieving tags. + const tagIdsPromise: Promise = + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', track.tagIds) + .then((as: any) => as.map((a: any) => a['id'])); - // Start retrieving album. - const albumIdPromise: Promise = - knex.select('id') + // Start retrieving album. + const albumIdPromise: Promise = + track.albumId ? + trx.select('id') .from('albums') - .where({ 'user': userId, 'albumId': track.albumId }) - .then((albums: any) => albums.map((album: any) => album['albumId'])) + .where({ 'user': userId, 'id': track.albumId }) + .then((albums: any) => albums.map((album: any) => album['id'])) .then((ids: number[]) => ids.length > 0 ? ids[0] : (() => null)() - ); - - // Wait for the requests to finish. - var [artists, tags, album] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdPromise]);; - - // 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)) || - (new Set((tags as number[]).map((a: any) => a['id'])) !== new Set(track.tagIds)) || - (album === null)) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Create the track. - const trackId = (await trx('tracks') - .insert({ - name: track.name, - storeLinks: JSON.stringify(track.storeLinks || []), - user: userId, - albumId: album, - }) - .returning('id') // Needed for Postgres - )[0]; - - // Link the artists via the linking table. - if (artists && artists.length) { - await trx('artists_tracks').insert( - artists.map((artistId: number) => { - return { - artistId: artistId, - trackId: trackId, - } - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await trx('tracks_tags').insert( - tags.map((tagId: number) => { - return { - trackId: trackId, - tagId: tagId, - } - }) - ) - } - - return trackId; - - } catch (e) { - trx.rollback(); + ) : + (async () => null)(); + + // Wait for the requests to finish. + var [artists, tags, album] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdPromise]); + + // Check that we found all artists and tags we need. + if (!_.isEqual((artists as number[]).sort(), track.artistIds.sort()) || + (!_.isEqual((tags as number[]).sort(), track.tagIds.sort())) || + (track.albumId && (album === null))) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Create the track. + const trackId = (await trx('tracks') + .insert({ + name: track.name, + storeLinks: JSON.stringify(track.storeLinks || []), + user: userId, + album: album || null, + }) + .returning('id') // Needed for Postgres + )[0]; + + // Link the artists via the linking table. + if (artists && artists.length) { + await trx('tracks_artists').insert( + artists.map((artistId: number) => { + return { + artistId: artistId, + trackId: trackId, + } + }) + ) + } + + // Link the tags via the linking table. + if (tags && tags.length) { + await trx('tracks_tags').insert( + tags.map((tagId: number) => { + return { + trackId: trackId, + tagId: tagId, + } + }) + ) + } + + return trackId; }) } export async function modifyTrack(userId: number, trackId: number, track: TrackBaseWithRefs, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start retrieving the track itself. - const trackIdPromise: Promise = - trx.select('id') - .from('tracks') - .where({ 'user': userId }) - .where({ id: trackId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - // Start retrieving artists if we are modifying those. - const artistIdsPromise: Promise = - track.artistIds ? - trx.select('artistId') - .from('artists_tracks') - .whereIn('artistId', track.artistIds) - .then((as: any) => as.map((a: any) => a['artistId'])) - : (async () => undefined)(); - - // Start retrieving tags if we are modifying those. - const tagIdsPromise = - track.tagIds ? - trx.select('id') - .from('tracks_tags') - .whereIn('tagId', track.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => undefined)(); - - // Wait for the requests to finish. - var [oldTrack, artists, tags] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise]);; - - // Check that we found all objects we need. - if ((!artists || new Set(artists.map((a: any) => a['id'])) !== new Set(track.artistIds)) || - (!tags || new Set(tags.map((a: any) => a['id'])) !== new Set(track.tagIds)) || - !oldTrack) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all to-be-linked resources were found.', - }; - throw e; - } - - // Modify the track. - var update: any = {}; - if ("name" in track) { update["name"] = track.name; } - if ("storeLinks" in track) { update["storeLinks"] = JSON.stringify(track.storeLinks || []); } - if ("albumId" in track) { update["albumId"] = track.albumId; } - - const modifyTrackPromise = trx('tracks') + // Start retrieving the track itself. + const trackIdPromise: Promise = + trx.select('id') + .from('tracks') .where({ 'user': userId }) - .where({ 'id': trackId }) - .update(update) - - // Remove unlinked artists. - const removeUnlinkedArtists = artists ? trx('artists_tracks') - .where({ 'trackId': trackId }) - .whereNotIn('artistId', track.artistIds || []) - .delete() : undefined; - - // Remove unlinked tags. - const removeUnlinkedTags = tags ? trx('tracks_tags') - .where({ 'trackId': trackId }) - .whereNotIn('tagId', track.tagIds || []) - .delete() : undefined; - - // Link new artists. - const addArtists = artists ? trx('artists_tracks') - .where({ 'trackId': trackId }) - .then((as: any) => as.map((a: any) => a['artistId'])) - .then((doneArtistIds: number[]) => { - // Get the set of artists that are not yet linked - const toLink = (artists || []).filter((id: number) => { - return !doneArtistIds.includes(id); - }); - const insertObjects = toLink.map((artistId: number) => { - return { - artistId: artistId, - trackId: trackId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('artists_tracks').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - const addTags = tags ? trx('tracks_tags') - .where({ 'trackId': trackId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) - .then((doneTagIds: number[]) => { - // Get the set of tags that are not yet linked - const toLink = tags.filter((id: number) => { - return !doneTagIds.includes(id); - }); - const insertObjects = toLink.map((tagId: number) => { - return { - tagId: tagId, - trackId: trackId, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('tracks_tags').insert(obj) - ) - ); - }) : undefined; - - // Wait for all operations to finish. - await Promise.all([ - modifyTrackPromise, - removeUnlinkedArtists, - removeUnlinkedTags, - addArtists, - addTags, - ]); - - return; - - } catch (e) { - trx.rollback(); + .where({ id: trackId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + // Start retrieving artists if we are modifying those. + const artistIdsPromise: Promise = + track.artistIds ? + trx.select('artistId') + .from('artists_tracks') + .whereIn('artistId', track.artistIds) + .then((as: any) => as.map((a: any) => a['artistId'])) + : (async () => undefined)(); + + // Start retrieving tags if we are modifying those. + const tagIdsPromise = + track.tagIds ? + trx.select('id') + .from('tracks_tags') + .whereIn('tagId', track.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => undefined)(); + + // Wait for the requests to finish. + var [oldTrack, artists, tags] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise]);; + + // Check that we found all objects we need. + if ((!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort())) || + (!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort())) || + !oldTrack) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all to-be-linked resources were found.', + }; throw e; } + + // Modify the track. + var update: any = {}; + if ("name" in track) { update["name"] = track.name; } + if ("storeLinks" in track) { update["storeLinks"] = JSON.stringify(track.storeLinks || []); } + if ("albumId" in track) { update["albumId"] = track.albumId; } + + const modifyTrackPromise = trx('tracks') + .where({ 'user': userId }) + .where({ 'id': trackId }) + .update(update) + + // Remove unlinked artists. + const removeUnlinkedArtists = artists ? trx('artists_tracks') + .where({ 'trackId': trackId }) + .whereNotIn('artistId', track.artistIds || []) + .delete() : undefined; + + // Remove unlinked tags. + const removeUnlinkedTags = tags ? trx('tracks_tags') + .where({ 'trackId': trackId }) + .whereNotIn('tagId', track.tagIds || []) + .delete() : undefined; + + // Link new artists. + const addArtists = artists ? trx('artists_tracks') + .where({ 'trackId': trackId }) + .then((as: any) => as.map((a: any) => a['artistId'])) + .then((doneArtistIds: number[]) => { + // Get the set of artists that are not yet linked + const toLink = (artists || []).filter((id: number) => { + return !doneArtistIds.includes(id); + }); + const insertObjects = toLink.map((artistId: number) => { + return { + artistId: artistId, + trackId: trackId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('artists_tracks').insert(obj) + ) + ); + }) : undefined; + + // Link new tags. + const addTags = tags ? trx('tracks_tags') + .where({ 'trackId': trackId }) + .then((ts: any) => ts.map((t: any) => t['tagId'])) + .then((doneTagIds: number[]) => { + // Get the set of tags that are not yet linked + const toLink = tags.filter((id: number) => { + return !doneTagIds.includes(id); + }); + const insertObjects = toLink.map((tagId: number) => { + return { + tagId: tagId, + trackId: trackId, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('tracks_tags').insert(obj) + ) + ); + }) : undefined; + + // Wait for all operations to finish. + await Promise.all([ + modifyTrackPromise, + removeUnlinkedArtists, + removeUnlinkedTags, + addArtists, + addTags, + ]); + + return; }) } export async function deleteTrack(userId: number, trackId: number, knex: Knex): Promise { await knex.transaction(async (trx) => { - try { - // Start by retrieving the track itself for sanity. - const confirmTrackId: number | undefined = - await trx.select('id') - .from('tracks') - .where({ 'user': userId }) - .where({ id: trackId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - if (!confirmTrackId) { - const e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceNotFound, - message: 'Not all resources were found.', - }; - throw e; - } - - // Start deleting artist associations with the track. - const deleteArtistsPromise: Promise = - trx.delete() - .from('artists_tracks') - .where({ 'trackId': trackId }); - - // Start deleting tag associations with the track. - const deleteTagsPromise: Promise = - trx.delete() - .from('tracks_tags') - .where({ 'trackId': trackId }); - - // Start deleting the track. - const deleteTrackPromise: Promise = - trx.delete() - .from('tracks') - .where({ id: trackId }); - - // Wait for the requests to finish. - await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTrackPromise]); - } catch (e) { - trx.rollback(); + // Start by retrieving the track itself for sanity. + const confirmTrackId: number | undefined = + await trx.select('id') + .from('tracks') + .where({ 'user': userId }) + .where({ id: trackId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + if (!confirmTrackId) { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; throw e; } + + // Start deleting artist associations with the track. + const deleteArtistsPromise: Promise = + trx.delete() + .from('artists_tracks') + .where({ 'trackId': trackId }); + + // Start deleting tag associations with the track. + const deleteTagsPromise: Promise = + trx.delete() + .from('tracks_tags') + .where({ 'trackId': trackId }); + + // Start deleting the track. + const deleteTrackPromise: Promise = + trx.delete() + .from('tracks') + .where({ id: trackId }); + + // Wait for the requests to finish. + await Promise.all([deleteArtistsPromise, deleteTagsPromise, deleteTrackPromise]); }) } \ No newline at end of file diff --git a/server/db/User.ts b/server/db/User.ts index 258489b..0590a7d 100644 --- a/server/db/User.ts +++ b/server/db/User.ts @@ -6,34 +6,30 @@ import { DBErrorKind, DBError } from '../endpoints/types'; export async function createUser(user: api.User, knex: Knex): Promise { return await knex.transaction(async (trx) => { - try { - // check if the user already exists - const newUser = (await trx - .select('id') - .from('users') - .where({ email: user.email }))[0]; - if (newUser) { - let e: DBError = { - name: "DBError", - kind: DBErrorKind.ResourceConflict, - message: "User with given e-mail already exists.", - } - throw e; + // check if the user already exists + const newUser = (await trx + .select('id') + .from('users') + .where({ email: user.email }))[0]; + if (newUser) { + let e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceConflict, + message: "User with given e-mail already exists.", } + throw e; + } - // Create the new user. - const passwordHash = sha512(user.password); - const userId = (await trx('users') - .insert({ - email: user.email, - passwordHash: passwordHash, - }) - .returning('id') // Needed for Postgres - )[0]; + // Create the new user. + const passwordHash = sha512(user.password); + const userId = (await trx('users') + .insert({ + email: user.email, + passwordHash: passwordHash, + }) + .returning('id') // Needed for Postgres + )[0]; - return userId; - } catch (e) { - trx.rollback(); - } + return userId; }) } \ No newline at end of file diff --git a/server/endpoints/Album.ts b/server/endpoints/Album.ts index cdf1770..7ef0329 100644 --- a/server/endpoints/Album.ts +++ b/server/endpoints/Album.ts @@ -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) => { - if (!api.checkPostAlbumRequest(req)) { + if (!api.checkPostAlbumRequest(req.body)) { const e: EndpointError = { name: "EndpointError", - message: 'Invalid PostAlbum request', + message: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), httpStatus: 400 }; throw e; @@ -40,14 +40,14 @@ export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) try { let id = await createAlbum(userId, reqObject, knex); - res.status(200).send(id); + res.status(200).send({ id: id }); } catch (e) { handleErrorsInEndpoint(e); } } export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkPutAlbumRequest(req)) { + if (!api.checkPutAlbumRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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) => { - if (!api.checkPatchAlbumRequest(req)) { + if (!api.checkPatchAlbumRequest(req.body)) { const e: EndpointError = { name: "EndpointError", message: 'Invalid PatchAlbum request', diff --git a/server/endpoints/Artist.ts b/server/endpoints/Artist.ts index 1cdf455..1663ee2 100644 --- a/server/endpoints/Artist.ts +++ b/server/endpoints/Artist.ts @@ -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) => { - if (!api.checkPostArtistRequest(req)) { + if (!api.checkPostArtistRequest(req.body)) { const e: EndpointError = { name: "EndpointError", - message: 'Invalid PostArtist request', + message: 'Invalid PostArtist request: ' + JSON.stringify(req.body), httpStatus: 400 }; 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) => { - if (!api.checkPutArtistRequest(req)) { + if (!api.checkPutArtistRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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) => { - if (!api.checkPatchArtistRequest(req)) { + if (!api.checkPatchArtistRequest(req.body)) { const e: EndpointError = { name: "EndpointError", message: 'Invalid PatchArtist request', diff --git a/server/endpoints/ImportExport.ts b/server/endpoints/ImportExport.ts new file mode 100644 index 0000000..5b29868 --- /dev/null +++ b/server/endpoints/ImportExport.ts @@ -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 ], + ]; \ No newline at end of file diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts index b872362..9be5be5 100644 --- a/server/endpoints/Integration.ts +++ b/server/endpoints/Integration.ts @@ -6,7 +6,7 @@ import { createIntegration, deleteIntegration, getIntegration, listIntegrations, import { IntegrationDataWithId } from '../../client/src/api/api'; export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkPostIntegrationRequest(req)) { + if (!api.checkPostIntegrationRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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) => { - if (!api.checkPutIntegrationRequest(req)) { + if (!api.checkPutIntegrationRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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) => { - if (!api.checkPatchIntegrationRequest(req)) { + if (!api.checkPatchIntegrationRequest(req.body)) { const e: EndpointError = { name: "EndpointError", message: 'Invalid PatchIntegration request', diff --git a/server/endpoints/Query.ts b/server/endpoints/Query.ts index b0f3294..0e49515 100644 --- a/server/endpoints/Query.ts +++ b/server/endpoints/Query.ts @@ -18,7 +18,7 @@ export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => console.log("User ", userId, ": Query ", reqObject); try { - let r = doQuery(userId, reqObject, knex); + let r = await doQuery(userId, reqObject, knex); res.status(200).send(r); } catch (e) { handleErrorsInEndpoint(e); diff --git a/server/endpoints/Tag.ts b/server/endpoints/Tag.ts index 6e82dab..8a1e06a 100644 --- a/server/endpoints/Tag.ts +++ b/server/endpoints/Tag.ts @@ -2,12 +2,13 @@ import * as api from '../../client/src/api/api'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; import Knex from 'knex'; import { createTag, deleteTag, getTag, mergeTag, modifyTag } from '../db/Tag'; +import { getAllJSDocTagsOfKind } from 'typescript'; export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkPostTagRequest(req)) { + if (!api.checkPostTagRequest(req.body)) { const e: EndpointError = { name: "EndpointError", - message: 'Invalid PostTag request', + message: 'Invalid PostTag request' + JSON.stringify(req.body), httpStatus: 400 }; 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) => { - if (!api.checkPutTagRequest(req)) { + if (!api.checkPutTagRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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) => { - if (!api.checkPatchTagRequest(req)) { + if (!api.checkPatchTagRequest(req.body)) { const e: EndpointError = { name: "EndpointError", message: 'Invalid PatchTag request', diff --git a/server/endpoints/Track.ts b/server/endpoints/Track.ts index cc649aa..c9a8a6e 100644 --- a/server/endpoints/Track.ts +++ b/server/endpoints/Track.ts @@ -5,7 +5,7 @@ import asJson from '../lib/asJson'; import { createTrack, deleteTrack, getTrack, modifyTrack } from '../db/Track'; export const PostTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkPostTrackRequest(req)) { + if (!api.checkPostTrackRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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); try { + let id = await createTrack(userId, reqObject, knex); res.status(200).send({ - id: await createTrack(userId, reqObject, knex) + id: id, }); } 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) => { - if (!api.checkPutTrackRequest(req)) { + if (!api.checkPutTrackRequest(req.body)) { const e: EndpointError = { name: "EndpointError", 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) => { - if (!api.checkPatchTrackRequest(req)) { + if (!api.checkPatchTrackRequest(req.body)) { const e: EndpointError = { name: "EndpointError", message: 'Invalid PatchTrack request', diff --git a/server/migrations/20200828124218_init_db.ts b/server/migrations/20200828124218_init_db.ts index 70fc0ab..6cc8d24 100644 --- a/server/migrations/20200828124218_init_db.ts +++ b/server/migrations/20200828124218_init_db.ts @@ -9,8 +9,8 @@ export async function up(knex: Knex): Promise { table.increments('id'); table.string('name'); table.string('storeLinks') - table.integer('user').unsigned().notNullable().defaultTo(1); - table.integer('album').unsigned().defaultTo(null); + table.integer('user').unsigned().notNullable().defaultTo(1).references('users.id'); + table.integer('album').unsigned().defaultTo(null).references('albums.id'); } ) @@ -21,7 +21,7 @@ export async function up(knex: Knex): Promise { table.increments('id'); table.string('name'); 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 { table.increments('id'); table.string('name'); 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 { table.increments('id'); table.string('name'); 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 { 'integrations', (table: any) => { 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('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify) 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 { 'tracks_artists', (table: any) => { table.increments('id'); - table.integer('trackId'); - table.integer('artistId'); + table.integer('trackId').references('tracks.id'); + table.integer('artistId').references('artists.id'); table.unique(['trackId', 'artistId']) } ) @@ -87,8 +87,8 @@ export async function up(knex: Knex): Promise { 'tracks_tags', (table: any) => { table.increments('id'); - table.integer('trackId'); - table.integer('tagId'); + table.integer('trackId').references('tracks.id'); + table.integer('tagId').references('tags.id'); table.unique(['trackId', 'tagId']) } ) @@ -98,8 +98,8 @@ export async function up(knex: Knex): Promise { 'artists_tags', (table: any) => { table.increments('id'); - table.integer('artistId'); - table.integer('tagId'); + table.integer('artistId').references('artists.id'); + table.integer('tagId').references('tags.id'); table.unique(['artistId', 'tagId']) } ) @@ -109,8 +109,8 @@ export async function up(knex: Knex): Promise { 'albums_tags', (table: any) => { table.increments('id'); - table.integer('tagId'); - table.integer('albumId'); + table.integer('tagId').references('tags.id'); + table.integer('albumId').references('albums.id'); table.unique(['albumId', 'tagId']) } ) @@ -120,8 +120,8 @@ export async function up(knex: Knex): Promise { 'artists_albums', (table: any) => { table.increments('id'); - table.integer('artistId'); - table.integer('albumId'); + table.integer('artistId').references('artists.id'); + table.integer('albumId').references('albums.id'); table.unique(['artistId', 'albumId']) } ) diff --git a/server/package.json b/server/package.json index a4d50b9..6bd9809 100644 --- a/server/package.json +++ b/server/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "scripts": { "start": "ts-node server.ts", - "dev": "nodemon server.ts", + "dev": "API='/api' nodemon server.ts", "build": "tsc", "test": "ts-node node_modules/jasmine/bin/jasmine --config=test/jasmine.json" },