diff --git a/client/src/api.ts b/client/src/api.ts index 1de23c8..0029586 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -159,9 +159,9 @@ export interface SongDetailsRequest { } export interface SongDetailsResponse { title: string, storeLinks: string[], - artistIds: number[], - albumIds: number[], - tagIds: number[], + artists?: ArtistDetailsResponseWithId[], + albums?: AlbumDetailsResponseWithId[], + tags?: TagDetailsResponseWithId[], } export function checkSongDetailsRequest(req: any): boolean { return true; @@ -172,7 +172,7 @@ export const ArtistDetailsEndpoint = '/artist/:id'; export interface ArtistDetailsRequest { } export interface ArtistDetailsResponse { name: string, - tagIds: number[], + tags?: TagDetailsResponseWithId[], storeLinks: string[], } export function checkArtistDetailsRequest(req: any): boolean { @@ -244,9 +244,9 @@ export const AlbumDetailsEndpoint = '/album/:id'; export interface AlbumDetailsRequest { } export interface AlbumDetailsResponse { name: string; - tagIds: number[]; - artistIds: number[]; - songIds: number[]; + artists?: ArtistDetailsResponseWithId[], + songs?: SongDetailsResponseWithId[], + tags?: TagDetailsResponseWithId[], storeLinks: string[]; } export function checkAlbumDetailsRequest(req: any): boolean { @@ -468,4 +468,9 @@ export interface DeleteIntegrationRequest { } export interface DeleteIntegrationResponse { } export function checkDeleteIntegrationRequest(req: any): boolean { return true; -} \ No newline at end of file +} + +export interface ArtistDetailsResponseWithId extends ArtistDetailsResponse { id: number } +export interface AlbumDetailsResponseWithId extends AlbumDetailsResponse { id: number } +export interface TagDetailsResponseWithId extends TagDetailsResponse { id: number } +export interface SongDetailsResponseWithId extends SongDetailsResponse { id: number } \ 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 e955882..aea23b3 100644 --- a/client/src/components/windows/manage_links/BatchLinkDialog.tsx +++ b/client/src/components/windows/manage_links/BatchLinkDialog.tsx @@ -1,5 +1,5 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; -import { Box, Button, Checkbox, createStyles, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, FormControlLabel, List, ListItem, ListItemIcon, ListItemText, makeStyles, MenuItem, Paper, Select, Theme, Typography } from "@material-ui/core"; +import { Box, Button, Checkbox, createStyles, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, FormControlLabel, LinearProgress, List, ListItem, ListItemIcon, ListItemText, makeStyles, MenuItem, Paper, Select, Theme, Typography } from "@material-ui/core"; import StoreLinkIcon from '../../common/StoreLinkIcon'; import { $enum } from 'ts-enum-util'; import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations'; @@ -11,6 +11,7 @@ import asyncPool from "tiny-async-pool"; import { getSong } from '../../../lib/backend/songs'; import { getAlbum } from '../../../lib/backend/albums'; import { getArtist } from '../../../lib/backend/artists'; +import { modifyAlbum, modifyArtist, modifySong } from '../../../lib/saveChanges'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -24,6 +25,7 @@ enum BatchJobState { Idle = 0, Collecting, Running, + Finished, } interface Task { @@ -99,11 +101,13 @@ async function doLinking( console.log("Linking start!", toLink); // Start the collecting phase. - setStatus({ - state: BatchJobState.Collecting, - numTasks: 0, - tasksSuccess: 0, - tasksFailed: 0, + setStatus((s: any) => { + return { + state: BatchJobState.Collecting, + numTasks: 0, + tasksSuccess: 0, + tasksFailed: 0, + } }); console.log("Starting collection"); @@ -127,17 +131,30 @@ async function doLinking( console.log("Done collecting.", tasks) // Start the linking phase. setStatus((status: BatchJobStatus) => { - status.state = BatchJobState.Running; - status.numTasks = tasks.length; - console.log("Collected status:", status) - return status; + return { + ...status, + state: BatchJobState.Running, + numTasks: tasks.length + } }); let makeJob: (t: Task) => Promise = (t: Task) => { let integration = integrations.find((i: IntegrationState) => i.id === t.integrationId); return (async () => { - let onSuccess = () => setStatus((s: BatchJobStatus) => { s.tasksSuccess += 1; return s; }); - let onFail = () => setStatus((s: BatchJobStatus) => { s.tasksFailed += 1; return s; }); + let onSuccess = () => + setStatus((s: BatchJobStatus) => { + return { + ...s, + tasksSuccess: s.tasksSuccess + 1, + } + }); + let onFail = () => + setStatus((s: BatchJobStatus) => { + return { + ...s, + tasksFailed: s.tasksFailed + 1, + } + }); try { if (integration === undefined) { return; } console.log('integration search:', integration) @@ -154,18 +171,38 @@ async function doLinking( [ItemType.Artist]: getArtist, } let queryFuncs: any = { - [ItemType.Song]: (s: any) => `${s.title}`, - [ItemType.Album]: (s: any) => `${s.name}`, + [ItemType.Song]: (s: any) => `${s.title}` + + `${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}`}` + + `${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}`}`, + [ItemType.Album]: (s: any) => `${s.name}` + + `${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}`}`, [ItemType.Artist]: (s: any) => `${s.name}`, } - let query = queryFuncs[t.itemType](await getFuncs[t.itemType](t.itemId)); + let modifyFuncs: any = { + [ItemType.Song]: modifySong, + [ItemType.Album]: modifyAlbum, + [ItemType.Artist]: modifyArtist, + } + let item = await getFuncs[t.itemType](t.itemId); + let query = queryFuncs[t.itemType](item); let candidates = await searchFuncs[t.itemType]( query, 1, ); + var success = false; + if (candidates && candidates.length && candidates.length > 0 && candidates[0].url) { + await modifyFuncs[t.itemType]( + t.itemId, + { + storeLinks: [...item.storeLinks, candidates[0].url] + } + ) + success = true; + } + console.log(query, candidates); - if (candidates && candidates.length && candidates.length > 0) { + if (success) { onSuccess(); } else { onFail(); @@ -178,13 +215,14 @@ async function doLinking( })(); } - await asyncPool(4, tasks, makeJob); + await asyncPool(8, tasks, makeJob); // Finalize. setStatus((status: BatchJobStatus) => { - status.state = BatchJobState.Idle; - console.log("Done running:", status) - return status; + return { + ...status, + state: BatchJobState.Finished, + } }); } @@ -193,16 +231,31 @@ function ProgressDialog(props: { onClose: () => void, status: BatchJobStatus, }) { + let donePercent = ((props.status.tasksFailed + props.status.tasksSuccess) / (props.status.numTasks || 1)) * 100; return - Batch linking in progress... + {props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running && + Batch linking in progress...} + {props.status.state === BatchJobState.Finished && + Batch linking finished} - - Closing or refreshing this page will interrupt and abort the process. - + {props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running && + + Closing or refreshing this page will interrupt and abort the process. + } + + + + Found: {props.status.tasksSuccess}
+ Failed: {props.status.tasksFailed}
+ Total: {props.status.numTasks}
+
+ {props.status.state === BatchJobState.Finished && + + }
} @@ -380,8 +433,15 @@ export default function BatchLinkDialog(props: { }} /> { }} + open={jobStatus.state === BatchJobState.Collecting || jobStatus.state === BatchJobState.Running || jobStatus.state === BatchJobState.Finished} + onClose={() => { + setJobStatus({ + numTasks: 0, + tasksSuccess: 0, + tasksFailed: 0, + state: BatchJobState.Idle, + }) + }} status={jobStatus} /> diff --git a/client/src/lib/backend/queries.tsx b/client/src/lib/backend/queries.tsx index 593e5e0..bc666c1 100644 --- a/client/src/lib/backend/queries.tsx +++ b/client/src/lib/backend/queries.tsx @@ -24,17 +24,18 @@ export async function queryItems( tags: number, songs: number, }> { + console.log("Types:", types); var q: serverApi.QueryRequest = { query: query ? toApiQuery(query) : {}, offsetsLimits: { - artistOffset: (serverApi.ItemType.Artist in types) ? (offset || 0) : undefined, - artistLimit: (serverApi.ItemType.Artist in types) ? (limit || -1) : undefined, - albumOffset: (serverApi.ItemType.Album in types) ? (offset || 0) : undefined, - albumLimit: (serverApi.ItemType.Album in types) ? (limit || -1) : undefined, - songOffset: (serverApi.ItemType.Song in types) ? (offset || 0) : undefined, - songLimit: (serverApi.ItemType.Song in types) ? (limit || -1) : undefined, - tagOffset: (serverApi.ItemType.Tag in types) ? (offset || 0) : undefined, - tagLimit: (serverApi.ItemType.Tag in types) ? (limit || -1) : undefined, + artistOffset: (types.includes(serverApi.ItemType.Artist)) ? (offset || 0) : undefined, + artistLimit: (types.includes(serverApi.ItemType.Artist)) ? (limit || -1) : undefined, + albumOffset: (types.includes(serverApi.ItemType.Album)) ? (offset || 0) : undefined, + albumLimit: (types.includes(serverApi.ItemType.Album)) ? (limit || -1) : undefined, + songOffset: (types.includes(serverApi.ItemType.Song)) ? (offset || 0) : undefined, + songLimit: (types.includes(serverApi.ItemType.Song)) ? (limit || -1) : undefined, + tagOffset: (types.includes(serverApi.ItemType.Tag)) ? (offset || 0) : undefined, + tagLimit: (types.includes(serverApi.ItemType.Tag)) ? (limit || -1) : undefined, }, ordering: { orderBy: { @@ -45,6 +46,8 @@ export async function queryItems( responseType: responseType, }; + console.log(q); + const requestOpts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx b/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx index ea35b1d..5737bd2 100644 --- a/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx +++ b/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx @@ -35,7 +35,7 @@ export function extractInitialData(text: string): any | undefined { export function parseSongs(initialData: any): IntegrationSong[] { try { - var songMusicResponsiveListItemRenderers: any[] = []; + var musicResponsiveListItemRenderers: any[] = []; // Scrape for any "Song"-type items. initialData.contents.sectionListRenderer.contents.forEach((c: any) => { @@ -45,13 +45,13 @@ export function parseSongs(initialData: any): IntegrationSong[] { cc.musicResponsiveListItemRenderer.flexColumns && cc.musicResponsiveListItemRenderer.flexColumns[1] .musicResponsiveListItemFlexColumnRenderer.text.runs[0].text === "Song") { - songMusicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer); - } + musicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer); + } }) } }) - return songMusicResponsiveListItemRenderers.map((s: any) => { + return musicResponsiveListItemRenderers.map((s: any) => { let videoId = s.doubleTapCommand.watchEndpoint.videoId; let columns = s.flexColumns; @@ -94,6 +94,82 @@ export function parseSongs(initialData: any): IntegrationSong[] { } } +export function parseArtists(initialData: any): IntegrationSong[] { + try { + var musicResponsiveListItemRenderers: any[] = []; + + // Scrape for any "Artist"-type items. + initialData.contents.sectionListRenderer.contents.forEach((c: any) => { + if (c.musicShelfRenderer) { + c.musicShelfRenderer.contents.forEach((cc: any) => { + if (cc.musicResponsiveListItemRenderer && + cc.musicResponsiveListItemRenderer.flexColumns && + cc.musicResponsiveListItemRenderer.flexColumns[1] + .musicResponsiveListItemFlexColumnRenderer.text.runs[0].text === "Artist") { + musicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer); + } + }) + } + }) + + return musicResponsiveListItemRenderers.map((s: any) => { + let browseId = s.navigationEndpoint.browseEndpoint.browseId; + let columns = s.flexColumns; + + if (columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text !== "Artist") { + throw new Error('artist item doesnt match scraper expectation'); + } + let name = columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text; + + return { + name: name, + url: `https://music.youtube.com/browse/${browseId}`, + } + }) + } catch (e) { + console.log("Error parsing artists:", e.message); + return []; + } +} + +export function parseAlbums(initialData: any): IntegrationSong[] { + try { + var musicResponsiveListItemRenderers: any[] = []; + + // Scrape for any "Artist"-type items. + initialData.contents.sectionListRenderer.contents.forEach((c: any) => { + if (c.musicShelfRenderer) { + c.musicShelfRenderer.contents.forEach((cc: any) => { + if (cc.musicResponsiveListItemRenderer && + cc.musicResponsiveListItemRenderer.flexColumns && + ["Album", "Single"].includes(cc.musicResponsiveListItemRenderer.flexColumns[1] + .musicResponsiveListItemFlexColumnRenderer.text.runs[0].text)) { + musicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer); + } + }) + } + }) + + return musicResponsiveListItemRenderers.map((s: any) => { + let browseId = s.navigationEndpoint.browseEndpoint.browseId; + let columns = s.flexColumns; + + if (!["Album", "Single"].includes(columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text)) { + throw new Error('album item doesnt match scraper expectation'); + } + let name = columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text; + + return { + name: name, + url: `https://music.youtube.com/browse/${browseId}`, + } + }) + } catch (e) { + console.log("Error parsing albums:", e.message); + return []; + } +} + export default class YoutubeMusicWebScraper extends Integration { integrationId: number; @@ -127,8 +203,6 @@ export default class YoutubeMusicWebScraper extends Integration { let text = await response.text(); let songs = parseSongs(extractInitialData(text)); - console.log("Found songs", songs); - if (!Array.isArray(songs) || songs.length === 0 || songs[0].title !== "No One Knows") { throw new Error("Test failed; No One Knows was not correctly identified."); } @@ -142,61 +216,20 @@ export default class YoutubeMusicWebScraper extends Integration { let text = await response.text(); return parseSongs(extractInitialData(text)); } - async searchAlbum(query: string, limit: number): Promise { return []; } - async searchArtist(query: string, limit: number): Promise { return []; } + async searchAlbum(query: string, limit: number): Promise { + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + + `/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`); - async search(query: string, type: SearchType, limit: number): - Promise { + let text = await response.text(); + return parseAlbums(extractInitialData(text)); + } + async searchArtist(query: string, limit: number): Promise { + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + + `/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`); - return []; - // const response = await fetch( - // (process.env.REACT_APP_BACKEND || "") + - // `/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}`); - - // if (!response.ok) { - // throw new Error("Spotify Client Credentials search failed: " + JSON.stringify(response)); - // } - - // let json = await response.json(); - - // console.log("Response:", json); - - // switch(type) { - // case SearchType.Song: { - // return json.tracks.items.map((r: any): IntegrationSong => { - // return { - // title: r.name, - // url: r.external_urls.spotify, - // artist: { - // name: r.artists && r.artists[0].name, - // url: r.artists && r.artists[0].external_urls.spotify, - // }, - // album: { - // name: r.album && r.album.name, - // url: r.album && r.album.external_urls.spotify, - // } - // } - // }) - // } - // case SearchType.Artist: { - // return json.artists.items.map((r: any): IntegrationArtist => { - // return { - // name: r.name, - // url: r.external_urls.spotify, - // } - // }) - // } - // case SearchType.Album: { - // return json.albums.items.map((r: any): IntegrationAlbum => { - // return { - // name: r.name, - // url: r.external_urls.spotify, - // artist: { - // name: r.artists[0].name, - // url: r.artists[0].external_urls.spotify, - // }, - // } - // }) - // } + let text = await response.text(); + return parseArtists(extractInitialData(text)); } } \ No newline at end of file diff --git a/server/endpoints/Album.ts b/server/endpoints/Album.ts index 5442db7..3af16ea 100644 --- a/server/endpoints/Album.ts +++ b/server/endpoints/Album.ts @@ -4,299 +4,311 @@ import Knex from 'knex'; import asJson from '../lib/asJson'; export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkAlbumDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid GetAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } + if (!api.checkAlbumDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid GetAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } - const { id: userId } = req.user; + const { id: userId } = req.user; - try { - // Start transfers for songs, tags and artists. - // Also request the album itself. - const tagIdsPromise = knex.select('tagId') - .from('albums_tags') - .where({ 'albumId': req.params.id }) - .then((tags: any) => { - return tags.map((tag: any) => tag['tagId']) - }); - const songIdsPromise = knex.select('songId') - .from('songs_albums') - .where({ 'albumId': req.params.id }) - .then((songs: any) => { - return songs.map((song: any) => song['songId']) - }); - const artistIdsPromise = knex.select('artistId') - .from('artists_albums') - .where({ 'albumId': req.params.id }) - .then((artists: any) => { - return artists.map((artist: any) => artist['artistId']) - }); - const albumPromise = knex.select('name', 'storeLinks') - .from('albums') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((albums: any) => albums[0]); - - // Wait for the requests to finish. - const [album, tags, songs, artists] = - await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]); - - // Respond to the request. - if (album) { - const response: api.AlbumDetailsResponse = { - name: album['name'], - artistIds: artists, - tagIds: tags, - songIds: songs, - storeLinks: asJson(album['storeLinks']), - }; - await res.send(response); - } else { - await res.status(404).send({}); - } - } catch (e) { + try { + // Start transfers for songs, tags and artists. + // Also request the album itself. + const tagsPromise: Promise = knex.select('tagId') + .from('albums_tags') + .where({ 'albumId': req.params.id }) + .then((tags: any) => { + return tags.map((tag: any) => tag['tagId']) + }) + .then((ids: number[]) => knex.select(['id', 'name', 'parentId']) + .from('tags') + .whereIn('id', ids)); + + const songsPromise: Promise = knex.select('songId') + .from('songs_albums') + .where({ 'albumId': req.params.id }) + .then((songs: any) => { + return songs.map((song: any) => song['songId']) + }) + .then((ids: number[]) => knex.select(['id', 'title', 'storeLinks']) + .from('songs') + .whereIn('id', ids)); + + const artistsPromise = knex.select('artistId') + .from('artists_albums') + .where({ 'albumId': req.params.id }) + .then((artists: any) => { + return artists.map((artist: any) => artist['artistId']) + }) + .then((ids: number[]) => knex.select(['id', 'name', 'storeLinks']) + .from('artists') + .whereIn('id', ids)); + + const albumPromise = knex.select('name', 'storeLinks') + .from('albums') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((albums: any) => albums[0]); + + // Wait for the requests to finish. + const [album, tags, songs, artists] = + await Promise.all([albumPromise, tagsPromise, songsPromise, artistsPromise]); + + // Respond to the request. + if (album) { + const response: api.AlbumDetailsResponse = { + name: album['name'], + artists: artists, + tags: tags, + songs: songs, + storeLinks: asJson(album['storeLinks']), + }; + await res.send(response); + } else { + await res.status(404).send({}); + } + } catch (e) { catchUnhandledErrors(e); -} + } } export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateAlbumRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateAlbumRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Post Album ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('id') - .from('artists') - .where({ 'user': userId }) - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Wait for the requests to finish. - var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);; - - // Check that we found all artists and tags we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length)) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the album. - const albumId = (await trx('albums') - .insert({ - name: reqObject.name, - storeLinks: JSON.stringify(reqObject.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, - } - }) - ) - } - - // Respond to the request. - const responseObject: api.CreateSongResponse = { - id: albumId + if (!api.checkCreateAlbumRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateAlbumRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Album ", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('id') + .from('artists') + .where({ 'user': userId }) + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('tags') + .where({ 'user': userId }) + .whereIn('id', reqObject.tagIds) + .then((as: any) => as.map((a: any) => a['id'])) : + (async () => { return [] })(); + + // Wait for the requests to finish. + var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);; + + // Check that we found all artists and tags we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length)) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); + throw e; + } + + // Create the album. + const albumId = (await trx('albums') + .insert({ + name: reqObject.name, + storeLinks: JSON.stringify(reqObject.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, + } + }) + ) + } + + // Respond to the request. + const responseObject: api.CreateSongResponse = { + id: albumId + }; + res.status(200).send(responseObject); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} + +export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkModifyAlbumRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; } + const reqObject: api.ModifyAlbumRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Album ", reqObject); + + await knex.transaction(async (trx) => { + try { + + // Start retrieving the album itself. + const albumPromise = trx.select('id') + .from('albums') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); + + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('artistId') + .from('artists_albums') + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['artistId'])) : + (async () => { return undefined })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('albums_tags') + .whereIn('id', reqObject.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return undefined })(); + + // Wait for the requests to finish. + var [album, artists, tags] = await Promise.all([albumPromise, artistIdsPromise, tagIdsPromise]);; - export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyAlbumRequest(req)) { + // Check that we found all objects we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || + !album) { const e: EndpointError = { - internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 + internalMessage: 'Not all albums and/or artists and/or tags exist for ModifyAlbum request: ' + JSON.stringify(req.body), + httpStatus: 400 }; throw e; - } - const reqObject: api.ModifyAlbumRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Put Album ", reqObject); - - await knex.transaction(async (trx) => { - try { - - // Start retrieving the album itself. - const albumPromise = trx.select('id') - .from('albums') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined); - - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('artistId') - .from('artists_albums') - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['artistId'])) : - (async () => { return undefined })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('albums_tags') - .whereIn('id', reqObject.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return undefined })(); - - // Wait for the requests to finish. - var [album, artists, tags] = await Promise.all([albumPromise, artistIdsPromise, tagIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - !album) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for ModifyAlbum request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; + } + + // Modify the album. + var update: any = {}; + if ("name" in reqObject) { update["name"] = reqObject.name; } + if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } + const modifyAlbumPromise = trx('albums') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .update(update) + + // Remove unlinked artists. + // TODO: test this! + const removeUnlinkedArtists = artists ? trx('artists_albums') + .where({ 'albumId': req.params.id }) + .whereNotIn('artistId', reqObject.artistIds || []) + .delete() : undefined; + + // Remove unlinked tags. + // TODO: test this! + const removeUnlinkedTags = tags ? trx('albums_tags') + .where({ 'albumId': req.params.id }) + .whereNotIn('tagId', reqObject.tagIds || []) + .delete() : undefined; + + // Link new artists. + // TODO: test this! + const addArtists = artists ? trx('artists_albums') + .where({ 'albumId': req.params.id }) + .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: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('artists_albums').insert(obj) + ) + ); + }) : undefined; + + // Link new tags. + // TODO: test this! + const addTags = tags ? trx('albums_tags') + .where({ 'albumId': req.params.id }) + .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: req.params.id, } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('albums_tags').insert(obj) + ) + ); + }) : undefined; - // Modify the album. - var update: any = {}; - if ("name" in reqObject) { update["name"] = reqObject.name; } - if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } - const modifyAlbumPromise = trx('albums') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update(update) - - // Remove unlinked artists. - // TODO: test this! - const removeUnlinkedArtists = artists ? trx('artists_albums') - .where({ 'albumId': req.params.id }) - .whereNotIn('artistId', reqObject.artistIds || []) - .delete() : undefined; - - // Remove unlinked tags. - // TODO: test this! - const removeUnlinkedTags = tags ? trx('albums_tags') - .where({ 'albumId': req.params.id }) - .whereNotIn('tagId', reqObject.tagIds || []) - .delete() : undefined; - - // Link new artists. - // TODO: test this! - const addArtists = artists ? trx('artists_albums') - .where({ 'albumId': req.params.id }) - .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: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('artists_albums').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - // TODO: test this! - const addTags = tags ? trx('albums_tags') - .where({ 'albumId': req.params.id }) - .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: req.params.id, - } - }) - - // 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, - addArtists, - addTags - ]); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) + // Wait for all operations to finish. + await Promise.all([ + modifyAlbumPromise, + removeUnlinkedArtists, + removeUnlinkedTags, + addArtists, + addTags + ]); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) } \ No newline at end of file diff --git a/server/endpoints/Artist.ts b/server/endpoints/Artist.ts index 0363423..f18c9c6 100644 --- a/server/endpoints/Artist.ts +++ b/server/endpoints/Artist.ts @@ -15,10 +15,17 @@ export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) const { id: userId } = req.user; try { - const tagIds = Array.from(new Set((await knex.select('tagId') + const tags: api.TagDetailsResponseWithId[] = await knex.select('tagId') .from('artists_tags') .where({ 'artistId': req.params.id }) - ).map((tag: any) => tag['tagId']))); + .then((ts: any) => { + return Array.from(new Set( + ts.map((tag: any) => tag['tagId']) + )) as number[]; + }) + .then((ids: number[]) => knex.select(['id', 'name', 'parentId']) + .from('tags') + .whereIn('id', ids)); const results = await knex.select(['id', 'name', 'storeLinks']) .from('artists') @@ -28,7 +35,7 @@ export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) if (results[0]) { const response: api.ArtistDetailsResponse = { name: results[0].name, - tagIds: tagIds, + tags: tags, storeLinks: asJson(results[0].storeLinks), } await res.send(response); diff --git a/server/endpoints/Song.ts b/server/endpoints/Song.ts index 0583b5f..03b8479 100644 --- a/server/endpoints/Song.ts +++ b/server/endpoints/Song.ts @@ -120,76 +120,86 @@ export const PostSong: EndpointHandler = async (req: any, res: any, knex: Knex) export const GetSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkSongDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid GetSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; + const e: EndpointError = { + internalMessage: 'Invalid GetSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; } const { id: userId } = req.user; try { - const tagIdsPromise: Promise = knex.select('tagId') - .from('songs_tags') - .where({ 'songId': req.params.id }) - .then((ts: any) => { - return Array.from(new Set( - ts.map((tag: any) => tag['tagId']) - )); - }) - - const albumIdsPromise: Promise = knex.select('albumId') - .from('songs_albums') - .where({ 'songId': req.params.id }) - .then((as: any) => { - return Array.from(new Set( - as.map((album: any) => album['albumId']) - )); - }) - - const artistIdsPromise: Promise = knex.select('artistId') - .from('songs_artists') - .where({ 'songId': req.params.id }) - .then((as: any) => { - return Array.from(new Set( - as.map((artist: any) => artist['artistId']) - )); - }) - const songPromise = await knex.select(['id', 'title', 'storeLinks']) - .from('songs') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .then((ss: any) => ss[0]) - - const [tags, albums, artists, song] = - await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]); - - if (song) { - const response: api.SongDetailsResponse = { - title: song.title, - tagIds: tags, - artistIds: artists, - albumIds: albums, - storeLinks: asJson(song.storeLinks), - } - await res.send(response); - } else { - await res.status(404).send({}); + const tagsPromise: Promise = knex.select('tagId') + .from('songs_tags') + .where({ 'songId': req.params.id }) + .then((ts: any) => { + return Array.from(new Set( + ts.map((tag: any) => tag['tagId']) + )) as number[]; + }) + .then((ids: number[]) => knex.select(['id', 'name', 'parentId']) + .from('tags') + .whereIn('id', ids)) + + const albumsPromise: Promise = knex.select('albumId') + .from('songs_albums') + .where({ 'songId': req.params.id }) + .then((as: any) => { + return Array.from(new Set( + as.map((album: any) => album['albumId']) + )) as number[]; + }) + .then((ids: number[]) => knex.select(['id', 'name', 'storeLinks']) + .from('albums') + .whereIn('id', ids)) + + const artistsPromise: Promise = knex.select('artistId') + .from('songs_artists') + .where({ 'songId': req.params.id }) + .then((as: any) => { + return Array.from(new Set( + as.map((artist: any) => artist['artistId']) + )) as number[]; + }) + .then((ids: number[]) => knex.select(['id', 'name', 'storeLinks']) + .from('albums') + .whereIn('id', ids)) + + const songPromise = await knex.select(['id', 'title', 'storeLinks']) + .from('songs') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .then((ss: any) => ss[0]) + + const [tags, albums, artists, song] = + await Promise.all([tagsPromise, albumsPromise, artistsPromise, songPromise]); + + if (song) { + const response: api.SongDetailsResponse = { + title: song.title, + tags: tags, + artists: artists, + albums: albums, + storeLinks: asJson(song.storeLinks), } + await res.send(response); + } else { + await res.status(404).send({}); + } } catch (e) { - catchUnhandledErrors(e) + catchUnhandledErrors(e) } } export const PutSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkModifySongRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid PutSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; + const e: EndpointError = { + internalMessage: 'Invalid PutSong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; } const reqObject: api.ModifySongRequest = req.body; const { id: userId } = req.user; @@ -197,176 +207,176 @@ export const PutSong: EndpointHandler = async (req: any, res: any, knex: Knex) = console.log("User ", userId, ": Put Song ", reqObject); await knex.transaction(async (trx) => { - try { - // Retrieve the song to be modified itself. - const songPromise = trx.select('id') - .from('songs') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Start retrieving artists. - const artistIdsPromise = reqObject.artistIds ? - trx.select('artistId') - .from('songs_artists') - .whereIn('id', reqObject.artistIds) - .then((as: any) => as.map((a: any) => a['artistId'])) : - (async () => { return undefined })(); - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('songs_tags') - .whereIn('id', reqObject.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return undefined })(); - - // Start retrieving albums. - const albumIdsPromise = reqObject.albumIds ? - trx.select('id') - .from('songs_albums') - .whereIn('id', reqObject.albumIds) - .then((as: any) => as.map((a: any) => a['albumId'])) : - (async () => { return undefined })(); - - // Wait for the requests to finish. - var [song, artists, tags, albums] = - await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || - (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - (reqObject.albumIds && albums.length !== reqObject.albumIds.length) || - !song) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the song. - var update: any = {}; - if ("title" in reqObject) { update["title"] = reqObject.title; } - if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } - const modifySongPromise = trx('songs') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update(update) - - // Remove unlinked artists. - // TODO: test this! - const removeUnlinkedArtists = artists ? trx('songs_artists') - .where({ 'songId': req.params.id }) - .whereNotIn('artistId', reqObject.artistIds || []) - .delete() : undefined; - - // Remove unlinked tags. - // TODO: test this! - const removeUnlinkedTags = tags ? trx('songs_tags') - .where({ 'songId': req.params.id }) - .whereNotIn('tagId', reqObject.tagIds || []) - .delete() : undefined; - - // Remove unlinked albums. - // TODO: test this! - const removeUnlinkedAlbums = albums ? trx('songs_albums') - .where({ 'songId': req.params.id }) - .whereNotIn('albumId', reqObject.albumIds || []) - .delete() : undefined; - - // Link new artists. - // TODO: test this! - const addArtists = artists ? trx('songs_artists') - .where({ 'songId': req.params.id }) - .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, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_artists').insert(obj) - ) - ); - }) : undefined; - - // Link new tags. - // TODO: test this! - const addTags = tags ? trx('songs_tags') - .where({ 'songId': req.params.id }) - .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, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_tags').insert(obj) - ) - ); - }) : undefined; - - // Link new albums. - // TODO: test this! - const addAlbums = albums ? trx('songs_albums') - .where({ 'albumId': req.params.id }) - .then((as: any) => as.map((a: any) => a['albumId'])) - .then((doneAlbumIds: number[]) => { - // Get the set of albums that are not yet linked - const toLink = albums.filter((id: number) => { - return !doneAlbumIds.includes(id); - }); - const insertObjects = toLink.map((albumId: number) => { - return { - albumId: albumId, - songId: req.params.id, - } - }) - - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('songs_albums').insert(obj) - ) - ); - }) : undefined; - - // Wait for all operations to finish. - await Promise.all([ - modifySongPromise, - removeUnlinkedArtists, - removeUnlinkedTags, - removeUnlinkedAlbums, - addArtists, - addTags, - addAlbums, - ]); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); + try { + // Retrieve the song to be modified itself. + const songPromise = trx.select('id') + .from('songs') + .where({ 'user': userId }) + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Start retrieving artists. + const artistIdsPromise = reqObject.artistIds ? + trx.select('artistId') + .from('songs_artists') + .whereIn('id', reqObject.artistIds) + .then((as: any) => as.map((a: any) => a['artistId'])) : + (async () => { return undefined })(); + + // Start retrieving tags. + const tagIdsPromise = reqObject.tagIds ? + trx.select('id') + .from('songs_tags') + .whereIn('id', reqObject.tagIds) + .then((ts: any) => ts.map((t: any) => t['tagId'])) : + (async () => { return undefined })(); + + // Start retrieving albums. + const albumIdsPromise = reqObject.albumIds ? + trx.select('id') + .from('songs_albums') + .whereIn('id', reqObject.albumIds) + .then((as: any) => as.map((a: any) => a['albumId'])) : + (async () => { return undefined })(); + + // Wait for the requests to finish. + var [song, artists, tags, albums] = + await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);; + + // Check that we found all objects we need. + if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) || + (reqObject.tagIds && tags.length !== reqObject.tagIds.length) || + (reqObject.albumIds && albums.length !== reqObject.albumIds.length) || + !song) { + const e: EndpointError = { + internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; } + + // Modify the song. + var update: any = {}; + if ("title" in reqObject) { update["title"] = reqObject.title; } + if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } + const modifySongPromise = trx('songs') + .where({ 'user': userId }) + .where({ 'id': req.params.id }) + .update(update) + + // Remove unlinked artists. + // TODO: test this! + const removeUnlinkedArtists = artists ? trx('songs_artists') + .where({ 'songId': req.params.id }) + .whereNotIn('artistId', reqObject.artistIds || []) + .delete() : undefined; + + // Remove unlinked tags. + // TODO: test this! + const removeUnlinkedTags = tags ? trx('songs_tags') + .where({ 'songId': req.params.id }) + .whereNotIn('tagId', reqObject.tagIds || []) + .delete() : undefined; + + // Remove unlinked albums. + // TODO: test this! + const removeUnlinkedAlbums = albums ? trx('songs_albums') + .where({ 'songId': req.params.id }) + .whereNotIn('albumId', reqObject.albumIds || []) + .delete() : undefined; + + // Link new artists. + // TODO: test this! + const addArtists = artists ? trx('songs_artists') + .where({ 'songId': req.params.id }) + .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, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_artists').insert(obj) + ) + ); + }) : undefined; + + // Link new tags. + // TODO: test this! + const addTags = tags ? trx('songs_tags') + .where({ 'songId': req.params.id }) + .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, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_tags').insert(obj) + ) + ); + }) : undefined; + + // Link new albums. + // TODO: test this! + const addAlbums = albums ? trx('songs_albums') + .where({ 'albumId': req.params.id }) + .then((as: any) => as.map((a: any) => a['albumId'])) + .then((doneAlbumIds: number[]) => { + // Get the set of albums that are not yet linked + const toLink = albums.filter((id: number) => { + return !doneAlbumIds.includes(id); + }); + const insertObjects = toLink.map((albumId: number) => { + return { + albumId: albumId, + songId: req.params.id, + } + }) + + // Link them + return Promise.all( + insertObjects.map((obj: any) => + trx('songs_albums').insert(obj) + ) + ); + }) : undefined; + + // Wait for all operations to finish. + await Promise.all([ + modifySongPromise, + removeUnlinkedArtists, + removeUnlinkedTags, + removeUnlinkedAlbums, + addArtists, + addTags, + addAlbums, + ]); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } }) } \ No newline at end of file