From bb9a1bdfa692fc139a1482c44ec830d2af90edff Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Thu, 3 Dec 2020 17:33:58 +0100 Subject: [PATCH] Major refactoring. It builds again! --- client/src/api.ts | 476 ------------------ client/src/api/api.ts | 13 + client/src/api/endpoints/auth.ts | 42 ++ client/src/api/endpoints/query.ts | 118 +++++ client/src/api/endpoints/resources.ts | 186 +++++++ client/src/api/types/resources.ts | 278 ++++++++++ client/src/components/MainWindow.tsx | 2 +- .../src/components/common/StoreLinkIcon.tsx | 14 +- .../components/querybuilder/QBAddElemMenu.tsx | 6 +- .../components/querybuilder/QBLeafElem.tsx | 4 +- .../components/querybuilder/QueryBuilder.tsx | 2 +- client/src/components/tables/ResultsTable.tsx | 48 +- client/src/components/windows/Windows.tsx | 22 +- .../components/windows/album/AlbumWindow.tsx | 54 +- .../windows/artist/ArtistWindow.tsx | 54 +- .../windows/manage_links/BatchLinkDialog.tsx | 100 ++-- .../manage_links/LinksStatusWidget.tsx | 32 +- .../windows/manage_tags/ManageTagsWindow.tsx | 2 +- .../windows/manage_tags/TagChange.tsx | 5 +- .../components/windows/query/QueryWindow.tsx | 28 +- .../windows/settings/IntegrationSettings.tsx | 34 +- .../src/components/windows/tag/TagWindow.tsx | 72 +-- .../EditTrackDialog.tsx} | 48 +- .../SongWindow.tsx => track/TrackWindow.tsx} | 72 ++- client/src/lib/backend/albums.tsx | 7 +- client/src/lib/backend/artists.tsx | 7 +- client/src/lib/backend/integrations.tsx | 17 +- client/src/lib/backend/queries.tsx | 185 +------ client/src/lib/backend/songs.tsx | 10 - client/src/lib/backend/tags.tsx | 10 +- client/src/lib/backend/tracks.tsx | 10 + client/src/lib/integration/Integration.tsx | 18 +- .../spotify/SpotifyClientCreds.tsx | 22 +- .../src/lib/integration/useIntegrations.tsx | 34 +- .../youtubemusic/YoutubeMusicWebScraper.tsx | 30 +- client/src/lib/query/Query.tsx | 26 +- client/src/lib/saveChanges.tsx | 16 +- client/src/lib/songGetters.tsx | 28 -- client/src/lib/trackGetters.tsx | 28 ++ client/src/lib/useAuth.tsx | 2 +- server/app.ts | 71 ++- server/db/Album.ts | 405 +++++++++++++++ server/db/Artist.ts | 323 ++++++++++++ server/db/ImportExport.ts | 204 ++++++++ server/db/Integration.ts | 135 +++++ server/db/Query.ts | 476 ++++++++++++++++++ server/db/Tag.ts | 274 ++++++++++ server/db/Track.ts | 343 +++++++++++++ server/db/User.ts | 39 ++ server/endpoints/Album.ts | 337 +++---------- server/endpoints/Artist.ts | 261 +++------- server/endpoints/Integration.ts | 228 +++------ server/endpoints/Query.ts | 445 +--------------- server/endpoints/RegisterUser.ts | 49 -- server/endpoints/Song.ts | 382 -------------- server/endpoints/Tag.ts | 320 +++--------- server/endpoints/Track.ts | 106 ++++ server/endpoints/User.ts | 31 ++ server/endpoints/types.ts | 49 +- server/integrations/integrations.ts | 6 +- server/lib/dbToApi.ts | 44 -- server/migrations/20200828124218_init_db.ts | 68 ++- server/migrations/20201110170100_add_users.ts | 73 --- .../20201113155620_add_integrations.ts | 24 - .../20201126082705_storelinks_to_text.ts | 58 --- .../test/integration/flows/IntegrationFlow.js | 34 +- server/test/integration/flows/helpers.js | 6 +- 67 files changed, 3853 insertions(+), 3100 deletions(-) delete mode 100644 client/src/api.ts create mode 100644 client/src/api/api.ts create mode 100644 client/src/api/endpoints/auth.ts create mode 100644 client/src/api/endpoints/query.ts create mode 100644 client/src/api/endpoints/resources.ts create mode 100644 client/src/api/types/resources.ts rename client/src/components/windows/{song/EditSongDialog.tsx => track/EditTrackDialog.tsx} (85%) rename client/src/components/windows/{song/SongWindow.tsx => track/TrackWindow.tsx} (67%) delete mode 100644 client/src/lib/backend/songs.tsx create mode 100644 client/src/lib/backend/tracks.tsx delete mode 100644 client/src/lib/songGetters.tsx create mode 100644 client/src/lib/trackGetters.tsx create mode 100644 server/db/Album.ts create mode 100644 server/db/Artist.ts create mode 100644 server/db/ImportExport.ts create mode 100644 server/db/Integration.ts create mode 100644 server/db/Query.ts create mode 100644 server/db/Tag.ts create mode 100644 server/db/Track.ts create mode 100644 server/db/User.ts delete mode 100644 server/endpoints/RegisterUser.ts delete mode 100644 server/endpoints/Song.ts create mode 100644 server/endpoints/Track.ts create mode 100644 server/endpoints/User.ts delete mode 100644 server/lib/dbToApi.ts delete mode 100644 server/migrations/20201110170100_add_users.ts delete mode 100644 server/migrations/20201113155620_add_integrations.ts delete mode 100644 server/migrations/20201126082705_storelinks_to_text.ts diff --git a/client/src/api.ts b/client/src/api.ts deleted file mode 100644 index 0029586..0000000 --- a/client/src/api.ts +++ /dev/null @@ -1,476 +0,0 @@ -// TODO: this file is located in the client src folder because -// otherwise, Create React App will refuse to compile it. -// Putting it in the server folder or in its own folder makes more sense. - -// This file represents the API interface for Mudbase's back-end. -// Each endpoint is described by its endpoint address, -// a request structure, a response structure and -// a checking function which determines request validity. - -export enum ItemType { - Song = 0, - Artist, - Album, - Tag -} - -export interface ArtistDetails { - artistId: number, - name: string, - storeLinks?: string[], -} -export function isArtistDetails(q: any): q is ArtistDetails { - return 'artistId' in q; -} -export interface AlbumDetails { - albumId: number, - name: string, - storeLinks?: string[], -} -export function isAlbumDetails(q: any): q is ArtistDetails { - return 'albumId' in q; -} -export interface TagDetails { - tagId: number, - name: string, - parent?: TagDetails, - storeLinks?: string[], -} -export function isTagDetails(q: any): q is TagDetails { - return 'tagId' in q; -} -export interface RankingDetails { - rankingId: number, - type: ItemType, // The item type being ranked - rankedId: number, // The item being ranked - context: ArtistDetails | TagDetails, - value: number, // The ranking (higher = better) -} -export function isRankingDetails(q: any): q is RankingDetails { - return 'rankingId' in q; -} -export interface SongDetails { - songId: number, - title: string, - artists?: ArtistDetails[], - albums?: AlbumDetails[], - tags?: TagDetails[], - storeLinks?: string[], - rankings?: RankingDetails[], -} -export function isSongDetails(q: any): q is SongDetails { - return 'songId' in q; -} - -// Query for items (POST). -export const QueryEndpoint = '/query'; -export enum QueryElemOp { - And = "AND", - Or = "OR", - Not = "NOT", -} -export enum QueryFilterOp { - Eq = "EQ", - Ne = "NE", - In = "IN", - NotIn = "NOTIN", - Like = "LIKE", -} -export enum QueryElemProperty { - songTitle = "songTitle", - songId = "songId", - artistName = "artistName", - artistId = "artistId", - albumName = "albumName", - albumId = "albumId", - tagId = "tagId", - songStoreLinks = "songStoreLinks", //Note: treated as a JSON string for filter operations - artistStoreLinks = "artistStoreLinks", //Note: treated as a JSON string for filter operations - albumStoreLinks = "albumStoreLinks", //Note: treated as a JSON string for filter operations -} -export enum OrderByType { - Name = 'name', -} -export enum QueryResponseType { - Details = 'details', // Returns detailed result items. - Ids = 'ids', // Returns IDs only. - Count = 'count', // Returns an item count only. -} -export interface QueryElem { - prop?: QueryElemProperty, - propOperand?: any, - propOperator?: QueryFilterOp, - children?: QueryElem[] - childrenOperator?: QueryElemOp, -} -export interface Ordering { - orderBy: { - type: OrderByType, - } - ascending: boolean, -} -export interface Query extends QueryElem { } -export interface QueryRequest { - query: Query, - offsetsLimits: OffsetsLimits, - ordering: Ordering, - responseType: QueryResponseType -} -export interface QueryResponse { - songs: SongDetails[] | number[] | number, // Details | IDs | count, depending on QueryResponseType - artists: ArtistDetails[] | number[] | number, - tags: TagDetails[] | number[] | number, - albums: AlbumDetails[] | number[] | number, -} -// Note: use -1 as an infinity limit. -export interface OffsetsLimits { - songOffset?: number, - songLimit?: number, - artistOffset?: number, - artistLimit?: number, - tagOffset?: number, - tagLimit?: number, - albumOffset?: number, - albumLimit?: number, -} -export function checkQueryElem(elem: any): boolean { - if (elem.childrenOperator && elem.children) { - elem.children.forEach((child: any) => { - if (!checkQueryElem(child)) { - return false; - } - }); - } - return (elem.childrenOperator && elem.children) || - ("prop" in elem && "propOperand" in elem && "propOperator" in elem) || - Object.keys(elem).length === 0; -} -export function checkQueryRequest(req: any): boolean { - return 'query' in req - && 'offsetsLimits' in req - && 'ordering' in req - && 'responseType' in req - && checkQueryElem(req.query); -} - -// Get song details (GET). -export const SongDetailsEndpoint = '/song/:id'; -export interface SongDetailsRequest { } -export interface SongDetailsResponse { - title: string, - storeLinks: string[], - artists?: ArtistDetailsResponseWithId[], - albums?: AlbumDetailsResponseWithId[], - tags?: TagDetailsResponseWithId[], -} -export function checkSongDetailsRequest(req: any): boolean { - return true; -} - -// Get artist details (GET). -export const ArtistDetailsEndpoint = '/artist/:id'; -export interface ArtistDetailsRequest { } -export interface ArtistDetailsResponse { - name: string, - tags?: TagDetailsResponseWithId[], - storeLinks: string[], -} -export function checkArtistDetailsRequest(req: any): boolean { - return true; -} - -// Create a new song (POST). -export const CreateSongEndpoint = '/song'; -export interface CreateSongRequest { - title: string; - artistIds?: number[]; - albumIds?: number[]; - tagIds?: number[]; - storeLinks?: string[]; -} -export interface CreateSongResponse { - id: number; -} -export function checkCreateSongRequest(req: any): boolean { - return "body" in req && - "title" in req.body; -} - -// Modify an existing song (PUT). -export const ModifySongEndpoint = '/song/:id'; -export interface ModifySongRequest { - title?: string; - artistIds?: number[]; - albumIds?: number[]; - tagIds?: number[]; - storeLinks?: string[]; -} -export interface ModifySongResponse { } -export function checkModifySongRequest(req: any): boolean { - return true; -} - -// Create a new album (POST). -export const CreateAlbumEndpoint = '/album'; -export interface CreateAlbumRequest { - name: string; - tagIds?: number[]; - artistIds?: number[]; - storeLinks?: string[]; -} -export interface CreateAlbumResponse { - id: number; -} -export function checkCreateAlbumRequest(req: any): boolean { - return "body" in req && - "name" in req.body; -} - -// Modify an existing album (PUT). -export const ModifyAlbumEndpoint = '/album/:id'; -export interface ModifyAlbumRequest { - name?: string; - tagIds?: number[]; - artistIds?: number[]; - storeLinks?: string[]; -} -export interface ModifyAlbumResponse { } -export function checkModifyAlbumRequest(req: any): boolean { - return true; -} - -// Get album details (GET). -export const AlbumDetailsEndpoint = '/album/:id'; -export interface AlbumDetailsRequest { } -export interface AlbumDetailsResponse { - name: string; - artists?: ArtistDetailsResponseWithId[], - songs?: SongDetailsResponseWithId[], - tags?: TagDetailsResponseWithId[], - storeLinks: string[]; -} -export function checkAlbumDetailsRequest(req: any): boolean { - return true; -} - -// Create a new artist (POST). -export const CreateArtistEndpoint = '/artist'; -export interface CreateArtistRequest { - name: string; - tagIds?: number[]; - storeLinks?: string[]; -} -export interface CreateArtistResponse { - id: number; -} -export function checkCreateArtistRequest(req: any): boolean { - return "body" in req && - "name" in req.body; -} - -// Modify an existing artist (PUT). -export const ModifyArtistEndpoint = '/artist/:id'; -export interface ModifyArtistRequest { - name?: string, - tagIds?: number[]; - storeLinks?: string[], -} -export interface ModifyArtistResponse { } -export function checkModifyArtistRequest(req: any): boolean { - return true; -} - -// Create a new tag (POST). -export const CreateTagEndpoint = '/tag'; -export interface CreateTagRequest { - name: string; - parentId?: number; -} -export interface CreateTagResponse { - id: number; -} -export function checkCreateTagRequest(req: any): boolean { - return "body" in req && - "name" in req.body; -} - -// Modify an existing tag (PUT). -export const ModifyTagEndpoint = '/tag/:id'; -export interface ModifyTagRequest { - name?: string, - parentId?: number; -} -export interface ModifyTagResponse { } -export function checkModifyTagRequest(req: any): boolean { - return true; -} - -// Get tag details (GET). -export const TagDetailsEndpoint = '/tag/:id'; -export interface TagDetailsRequest { } -export interface TagDetailsResponse { - name: string, - parentId?: number, -} -export function checkTagDetailsRequest(req: any): boolean { - return true; -} - -// Delete tag (DELETE). -export const DeleteTagEndpoint = '/tag/:id'; -export interface DeleteTagRequest { } -export interface DeleteTagResponse { } -export function checkDeleteTagRequest(req: any): boolean { - return true; -} - -// Merge tag (POST). -export const MergeTagEndpoint = '/tag/:id/merge/:toId'; -export interface MergeTagRequest { } -export interface MergeTagResponse { } -export function checkMergeTagRequest(req: any): boolean { - return true; -} - -// Register a user (POST). -// TODO: add e-mail verification. -export const RegisterUserEndpoint = '/register'; -export interface RegisterUserRequest { - email: string, - password: string, -} -export interface RegisterUserResponse { } -export function checkPassword(password: string): boolean { - const result = (password.length < 32) && - (password.length >= 8) && - password.split("").every(char => char.charCodeAt(0) <= 127) && // is ASCII - (/[a-z]/g.test(password)) && // has lowercase - (/[A-Z]/g.test(password)) && // has uppercase - (/[0-9]/g.test(password)) && // has number - (/[!@#$%^&*()_+/]/g.test(password)) // has special character; - - console.log("Password check for ", password, ": ", result); - return result; -} -export function checkEmail(email: string): boolean { - const re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; - const result = re.test(String(email).toLowerCase()); - console.log("Email check for ", email, ": ", result); - return result; -} -export function checkRegisterUserRequest(req: any): boolean { - return "body" in req && - "email" in req.body && - "password" in req.body && - checkEmail(req.body.email) && - checkPassword(req.body.password); -} - -// Note: Login is handled by Passport.js, so it is not explicitly written here. -export const LoginEndpoint = "/login"; -export const LogoutEndpoint = "/logout"; - -export enum IntegrationType { - SpotifyClientCredentials = "SpotifyClientCredentials", - YoutubeWebScraper = "YoutubeWebScraper", -} - -export enum ExternalStore { - GooglePlayMusic = "Google Play Music", - Spotify = "Spotify", - YoutubeMusic = "Youtube Music", -} - -// Links to external stores are identified by their domain or some -// other unique substring. These unique substrings are stored here. -export const StoreURLIdentifiers: Record = { - [ExternalStore.GooglePlayMusic]: 'play.google.com', - [ExternalStore.Spotify]: 'spotify.com', - [ExternalStore.YoutubeMusic]: 'music.youtube.com', -} - -export const IntegrationStores: Record = { - [IntegrationType.SpotifyClientCredentials]: ExternalStore.Spotify, - [IntegrationType.YoutubeWebScraper]: ExternalStore.YoutubeMusic, -} - -export interface SpotifyClientCredentialsDetails { - clientId: string, -} -export interface SpotifyClientCredentialsSecretDetails { - clientSecret: string, -} - -export interface YoutubeMusicWebScraperDetails {} -export interface YoutubeMusicWebScraperSecretDetails {} - -export type IntegrationDetails = SpotifyClientCredentialsDetails | YoutubeMusicWebScraperDetails; -export type IntegrationSecretDetails = SpotifyClientCredentialsSecretDetails | YoutubeMusicWebScraperSecretDetails; - -// Create a new integration (POST). -export const CreateIntegrationEndpoint = '/integration'; -export interface CreateIntegrationRequest { - name: string, - type: IntegrationType, - details: IntegrationDetails, - secretDetails: IntegrationSecretDetails, -} -export interface CreateIntegrationResponse { - id: number; -} -export function checkCreateIntegrationRequest(req: any): boolean { - return "body" in req && - "name" in req.body && - "type" in req.body && - "details" in req.body && - "secretDetails" in req.body && - (req.body.type in IntegrationType); -} - -// Modify an existing integration (PUT). -export const ModifyIntegrationEndpoint = '/integration/:id'; -export interface ModifyIntegrationRequest { - name?: string, - type?: IntegrationType, - details?: IntegrationDetails, - secretDetails?: IntegrationSecretDetails, -} -export interface ModifyIntegrationResponse { } -export function checkModifyIntegrationRequest(req: any): boolean { - if("type" in req.body && !(req.body.type in IntegrationType)) return false; - return true; -} - -// Get integration details (GET). -export const IntegrationDetailsEndpoint = '/integration/:id'; -export interface IntegrationDetailsRequest { } -export interface IntegrationDetailsResponse { - name: string, - type: IntegrationType, - details: IntegrationDetails, -} -export function checkIntegrationDetailsRequest(req: any): boolean { - return true; -} - -// List integrations (GET). -export const ListIntegrationsEndpoint = '/integration'; -export interface ListIntegrationsRequest { } -export interface ListIntegrationsItem extends IntegrationDetailsResponse { id: number } -export type ListIntegrationsResponse = ListIntegrationsItem[]; -export function checkListIntegrationsRequest(req: any): boolean { - return true; -} - -// Delete integration (DELETE). -export const DeleteIntegrationEndpoint = '/integration/:id'; -export interface DeleteIntegrationRequest { } -export interface DeleteIntegrationResponse { } -export function checkDeleteIntegrationRequest(req: any): boolean { - return true; -} - -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/api/api.ts b/client/src/api/api.ts new file mode 100644 index 0000000..7cc54af --- /dev/null +++ b/client/src/api/api.ts @@ -0,0 +1,13 @@ +// TODO: this file is located in the front-end src folder because +// otherwise, Create React App will refuse to compile it. +// Putting it in the server folder or in its own folder makes more sense. + +// This file represents the API interface for Mudbase's back-end. +// Each endpoint is described by its endpoint address, +// a request structure, a response structure and +// a checking function which determines request validity. + +export * from './types/resources'; +export * from './endpoints/auth'; +export * from './endpoints/resources'; +export * from './endpoints/query'; \ No newline at end of file diff --git a/client/src/api/endpoints/auth.ts b/client/src/api/endpoints/auth.ts new file mode 100644 index 0000000..4b37b9e --- /dev/null +++ b/client/src/api/endpoints/auth.ts @@ -0,0 +1,42 @@ +// Any endpoints which have to do with authentication, registration, e.d. + +import { User } from "../types/resources"; + +// Register a user (POST). +// TODO: add e-mail verification. +// TODO: add descriptive reason for failure. +export const RegisterUserEndpoint = '/register'; +export type RegisterUserRequest = User; +export interface RegisterUserResponse { } + +export function checkPassword(password: string): boolean { + const result = (password.length < 32) && + (password.length >= 8) && + password.split("").every(char => char.charCodeAt(0) <= 127) && // is ASCII + (/[a-z]/g.test(password)) && // has lowercase + (/[A-Z]/g.test(password)) && // has uppercase + (/[0-9]/g.test(password)) && // has number + (/[!@#$%^&*()_+/]/g.test(password)) // has special character; + + console.log("Password check for ", password, ": ", result); + return result; +} + +export function checkEmail(email: string): boolean { + const re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + const result = re.test(String(email).toLowerCase()); + console.log("Email check for ", email, ": ", result); + return result; +} + +export function checkRegisterUserRequest(req: any): boolean { + return "body" in req && + "email" in req.body && + "password" in req.body && + checkEmail(req.body.email) && + checkPassword(req.body.password); +} + +// Note: Login is handled by Passport.js, so it is not explicitly written here. +export const LoginEndpoint = "/login"; +export const LogoutEndpoint = "/logout"; \ No newline at end of file diff --git a/client/src/api/endpoints/query.ts b/client/src/api/endpoints/query.ts new file mode 100644 index 0000000..dbdf8da --- /dev/null +++ b/client/src/api/endpoints/query.ts @@ -0,0 +1,118 @@ +// Query for items (POST). + +import { AlbumWithId, ArtistWithId, TagWithId, TrackWithId } from "../types/resources"; + +export const QueryEndpoint = '/query'; + +// Combinational query operations +export enum QueryNodeOp { + And = "AND", + Or = "OR", + Not = "NOT", +} + +// Leaf (filter) query operations +export enum QueryLeafOp { + Eq = "EQ", + Ne = "NE", + In = "IN", + NotIn = "NOTIN", + Like = "LIKE", +} + +// Resource properties that can be queried on +export enum QueryElemProperty { + trackName = "trackName", + trackId = "trackId", + artistName = "artistName", + artistId = "artistId", + albumName = "albumName", + albumId = "albumId", + tagName = "tagName", + tagId = "tagId", + trackStoreLinks = "trackStoreLinks", //Note: treated as a JSON string for filter operations + artistStoreLinks = "artistStoreLinks", //Note: treated as a JSON string for filter operations + albumStoreLinks = "albumStoreLinks", //Note: treated as a JSON string for filter operations +} + +// Resource properties that can be ordered by +export enum OrderByType { + Name = 'name', +} + +// Levels of detail for the query response +export enum QueryResponseType { + Details = 'details', // Returns detailed result items. + Ids = 'ids', // Returns IDs only. + Count = 'count', // Returns an item count only. +} + +// A single query element (can be node or leaf) +export interface QueryElem { + // Leaf + prop?: QueryElemProperty, + propOperand?: any, + propOperator?: QueryLeafOp, + + // Node + children?: QueryElem[] + childrenOperator?: QueryNodeOp, +} + +// An ordering specification for a query. +export interface Ordering { + orderBy: { + type: OrderByType, + } + ascending: boolean, +} + +// Query request structure +export interface Query extends QueryElem { } +export interface QueryRequest { + query: Query, + offsetsLimits: OffsetsLimits, + ordering: Ordering, + responseType: QueryResponseType +} + +// Query response structure +export interface QueryResponse { + tracks: TrackWithId[] | number[] | number, // Details | IDs | count, depending on QueryResponseType + artists: ArtistWithId[] | number[] | number, + tags: TagWithId[] | number[] | number, + albums: AlbumWithId[] | number[] | number, +} + +// Note: use -1 as an infinity limit. +export interface OffsetsLimits { + trackOffset?: number, + trackLimit?: number, + artistOffset?: number, + artistLimit?: number, + tagOffset?: number, + tagLimit?: number, + albumOffset?: number, + albumLimit?: number, +} + +// Checking functions for query requests. +export function checkQueryElem(elem: any): boolean { + if (elem.childrenOperator && elem.children) { + elem.children.forEach((child: any) => { + if (!checkQueryElem(child)) { + return false; + } + }); + } + return (elem.childrenOperator && elem.children) || + ("prop" in elem && "propOperand" in elem && "propOperator" in elem) || + Object.keys(elem).length === 0; +} +export function checkQueryRequest(req: any): boolean { + return 'query' in req + && 'offsetsLimits' in req + && 'ordering' in req + && 'responseType' in req + && checkQueryElem(req.query); +} \ No newline at end of file diff --git a/client/src/api/endpoints/resources.ts b/client/src/api/endpoints/resources.ts new file mode 100644 index 0000000..e748207 --- /dev/null +++ b/client/src/api/endpoints/resources.ts @@ -0,0 +1,186 @@ +import { + Album, + AlbumBaseWithRefs, + AlbumWithRefs, + Artist, + ArtistBaseWithRefs, + ArtistWithRefs, + IntegrationData, + IntegrationDataWithId, + IntegrationDataWithSecret, + isAlbumBaseWithRefs, + isAlbumWithRefs, + isArtistBaseWithRefs, + isArtistWithRefs, + isIntegrationData, + isPartialIntegrationData, + isTagBaseWithRefs, + isTagWithRefs, + isTrackBaseWithRefs, + isTrackWithRefs, + PartialIntegrationData, + Tag, + TagBaseWithRefs, + TagWithRefs, + TrackBaseWithRefs, + TrackWithDetails, + TrackWithRefs +} from "../types/resources"; + +// The API supports RESTful access to single API resources: +// - GET for retrieving details about a single item +// - PUT for replacing a single item +// - POST for creating a new item +// - PATCH for modifying a single item +// - DELETE for deleting a single item +// +// The above are implemented for: +// - tracks +// - artists +// - albums +// - tags +// - integrations +// +// The following special requests exist in addition: +// - Merge a tag into another using a POST +// - List all integrations using a GET + +// Get track details (GET). +export const GetTrackEndpoint = '/track/:id'; +export type GetTrackResponse = TrackWithDetails; + +// Get artist details (GET). +export const GetArtistEndpoint = '/artist/:id'; +export type GetArtistResponse = Artist; + +// Get album details (GET). +export const GetAlbumEndpoint = "/album/:id"; +export type GetAlbumResponse = Album; + +// Get tag details (GET). +export const GetTagEndpoint = "/tag/:id"; +export type GetTagResponse = Tag; + +// Get integration details (GET). +export const GetIntegrationEndpoint = "/integration/:id"; +export type GetIntegrationResponse = IntegrationData; + +// Post new track (POST). +export const PostTrackEndpoint = "/track"; +export type PostTrackRequest = TrackWithRefs; +export interface PostTrackResponse { id: number }; +export const checkPostTrackRequest: (v: any) => boolean = isTrackWithRefs; + +// Post new artist (POST). +export const PostArtistEndpoint = "/artist"; +export type PostArtistRequest = ArtistWithRefs; +export interface PostArtistResponse { id: number }; +export const checkPostArtistRequest: (v: any) => boolean = isArtistWithRefs; + +// Post new album (POST). +export const PostAlbumEndpoint = "/album"; +export type PostAlbumRequest = AlbumWithRefs; +export interface PostAlbumResponse { id: number }; +export const checkPostAlbumRequest: (v: any) => boolean = isAlbumWithRefs; + +// Post new tag (POST). +export const PostTagEndpoint = "/tag"; +export type PostTagRequest = TagWithRefs; +export interface PostTagResponse { id: number }; +export const checkPostTagRequest: (v: any) => boolean = isTagWithRefs; + +// Post new integration (POST). +export const PostIntegrationEndpoint = "/integration"; +export type PostIntegrationRequest = IntegrationDataWithSecret; +export interface PostIntegrationResponse { id: number }; +export const checkPostIntegrationRequest: (v: any) => boolean = isIntegrationData; + +// Replace track (PUT). +export const PutTrackEndpoint = "/track/:id"; +export type PutTrackRequest = TrackWithRefs; +export type PutTrackResponse = void; +export const checkPutTrackRequest: (v: any) => boolean = isTrackWithRefs; + +// Replace artist (PUT). +export const PutArtistEndpoint = "/artist/:id"; +export type PutArtistRequest = ArtistWithRefs; +export type PutArtistResponse = void; +export const checkPutArtistRequest: (v: any) => boolean = isArtistWithRefs; + +// Replace album (PUT). +export const PutAlbumEndpoint = "/album/:id"; +export type PutAlbumRequest = AlbumWithRefs; +export type PutAlbumResponse = void; +export const checkPutAlbumRequest: (v: any) => boolean = isAlbumWithRefs; + +// Replace tag (PUT). +export const PutTagEndpoint = "/tag/:id"; +export type PutTagRequest = TagWithRefs; +export type PutTagResponse = void; +export const checkPutTagRequest: (v: any) => boolean = isTagWithRefs; + +// Replace integration (PUT). +export const PutIntegrationEndpoint = "/integration/:id"; +export type PutIntegrationRequest = IntegrationDataWithSecret; +export type PutIntegrationResponse = void; +export const checkPutIntegrationRequest: (v: any) => boolean = isIntegrationData; + +// Modify track (PATCH). +export const PatchTrackEndpoint = "/track/:id"; +export type PatchTrackRequest = TrackBaseWithRefs; +export type PatchTrackResponse = void; +export const checkPatchTrackRequest: (v: any) => boolean = isTrackBaseWithRefs; + +// Modify artist (PATCH). +export const PatchArtistEndpoint = "/artist/:id"; +export type PatchArtistRequest = ArtistBaseWithRefs; +export type PatchArtistResponse = void; +export const checkPatchArtistRequest: (v: any) => boolean = isArtistBaseWithRefs; + +// Modify album (PATCH). +export const PatchAlbumEndpoint = "/album/:id"; +export type PatchAlbumRequest = AlbumBaseWithRefs; +export type PatchAlbumResponse = void; +export const checkPatchAlbumRequest: (v: any) => boolean = isAlbumBaseWithRefs; + +// Modify tag (PATCH). +export const PatchTagEndpoint = "/tag/:id"; +export type PatchTagRequest = TagBaseWithRefs; +export type PatchTagResponse = void; +export const checkPatchTagRequest: (v: any) => boolean = isTagBaseWithRefs; + +// Modify integration (PATCH). +export const PatchIntegrationEndpoint = "/integration/:id"; +export type PatchIntegrationRequest = PartialIntegrationData; +export type PatchIntegrationResponse = void; +export const checkPatchIntegrationRequest: (v: any) => boolean = isPartialIntegrationData; + +// DELETE track. +export const DeleteTrackEndpoint = '/track/:id'; +export type DeleteTrackResponse = void + +// DELETE artist. +export const DeleteArtistEndpoint = '/artist/:id'; +export type DeleteArtistResponse = void + +// DELETE album. +export const DeleteAlbumEndpoint = "/album/:id"; +export type DeleteAlbumResponse = void + +// DELETE tag. +export const DeleteTagEndpoint = "/tag/:id"; +export type DeleteTagResponse = void + +// DELETE integration. +export const DeleteIntegrationEndpoint = "/integration/:id"; +export type DeleteIntegrationResponse = void + +// List integrations (GET). +export const ListIntegrationsEndpoint = "/integration"; +export type ListIntegrationsResponse = IntegrationDataWithId[]; + +// Merge tag (POST). +// This will tag any items which are tagged by :id +// with :toId instead, and then delete tag :id. +export const MergeTagEndpoint = '/tag/:id/merge/:toId'; +export type MergeTagResponse = void; \ No newline at end of file diff --git a/client/src/api/types/resources.ts b/client/src/api/types/resources.ts new file mode 100644 index 0000000..fdadd35 --- /dev/null +++ b/client/src/api/types/resources.ts @@ -0,0 +1,278 @@ +// Enumerates the different kinds of resources dealt with by the API. +export enum ResourceType { + Track = "track", + Artist = "artist", + Album = "album", + Tag = "tag" +} + +export interface TrackBase { + mbApi_typename: "track", + + name?: string, + storeLinks?: string[], + albumId?: number | null, +} +export interface TrackBaseWithRefs extends TrackBase { + artistIds?: number[], + albumId?: number | null, + tagIds?: number[], +} +export interface TrackBaseWithDetails extends TrackBase { + artists: ArtistWithId[], + album: AlbumWithId | null, + tags: TagWithId[], +} +export interface TrackWithDetails extends TrackBaseWithDetails { + name: string, +} +export interface TrackWithRefs extends TrackBaseWithRefs { + name: string, + artistIds: number[], + albumId: number | null, + tagIds: number[], +} +export interface Track extends TrackBase { + name: string, + album: AlbumWithId | null, +} +export interface TrackWithRefsWithId extends TrackWithRefs { + id: number, +} +export interface TrackWithDetailsWithId extends TrackWithDetails { + id: number, +} +export interface TrackWithId extends Track { + id: number, +} +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; +} +export function isTrackWithRefs(q: any): q is TrackWithRefs { + return isTrackBaseWithRefs(q) && "name" in q; +} + + +export interface ArtistBase { + mbApi_typename: "artist", + + name?: string, + storeLinks?: string[], +} +export interface ArtistBaseWithRefs extends ArtistBase { + albumIds?: number[], + tagIds?: number[], +} +export interface ArtistBaseWithDetails extends ArtistBase { + albums: AlbumWithId[], + tags: TagWithId[], +} +export interface ArtistWithDetails extends ArtistBaseWithDetails { + name: string, +} +export interface ArtistWithRefs extends ArtistBaseWithRefs { + name: string, + albumIds: number[], + tagIds: number[], +} +export interface Artist extends ArtistBase { + name: string, +} +export interface ArtistWithRefsWithId extends ArtistWithRefs { + id: number, +} +export interface ArtistWithDetailsWithId extends ArtistWithDetails { + id: number, +} +export interface ArtistWithId extends Artist { + id: number, +} +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; +} +export function isArtistWithRefs(q: any): q is ArtistWithRefs { + return isTrackBaseWithRefs(q) && "name" in q; +} + + +export interface AlbumBase { + mbApi_typename: "album", + + name?: string, + storeLinks?: string[], +} +export interface AlbumBaseWithRefs extends AlbumBase { + artistIds?: number[], + trackIds?: number[], + tagIds?: number[], +} +export interface AlbumBaseWithDetails extends AlbumBase { + artists: ArtistWithId[], + tracks: TrackWithId[], + tags: TagWithId[], +} +export interface AlbumWithDetails extends AlbumBaseWithDetails { + name: string, +} +export interface AlbumWithRefs extends AlbumBaseWithRefs { + name: string, + artistIds: number[], + trackIds: number[], + tagIds: number[], +} +export interface Album extends AlbumBase { + name: string, +} +export interface AlbumWithRefsWithId extends AlbumWithRefs { + id: number, +} +export interface AlbumWithDetailsWithId extends AlbumWithDetails { + id: number, +} +export interface AlbumWithId extends Album { + id: number, +} +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; +} +export function isAlbumWithRefs(q: any): q is AlbumWithRefs { + return isAlbumBaseWithRefs(q) && "name" in q; +} + + +export interface TagBase { + mbApi_typename: "tag", + + name?: string, +} +export interface TagBaseWithRefs extends TagBase { + parentId?: number | null, +} +export interface TagBaseWithDetails extends TagBase { + parent?: TagWithId | null, +} +export interface TagWithDetails extends TagBaseWithDetails { + name: string, +} +export interface TagWithRefs extends TagBaseWithRefs { + name: string, + parentId: number | null, +} +export interface Tag extends TagBase { + name: string, +} +export interface TagWithRefsWithId extends TagWithRefs { + id: number, +} +export interface TagWithDetailsWithId extends TagWithDetails { + id: number, +} +export interface TagWithId extends Tag { + id: number, +} +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; +} +export function isTagWithRefs(q: any): q is TagWithRefs { + return isTagBaseWithRefs(q) && "name" in q; +} + + +// There are several implemented integration solutions, +// enumerated here. +export enum IntegrationImpl { + SpotifyClientCredentials = "SpotifyClientCredentials", + YoutubeWebScraper = "YoutubeWebScraper", +} + +// External domains to integrate with are enumerated here. +export enum IntegrationWith { + GooglePlayMusic = "Google Play Music", + Spotify = "Spotify", + YoutubeMusic = "Youtube Music", +} + +// Links to integrated domains are identified by their domain or some +// other unique substring. These unique substrings are stored here. +export const IntegrationUrls: Record = { + [IntegrationWith.GooglePlayMusic]: 'play.google.com', + [IntegrationWith.Spotify]: 'spotify.com', + [IntegrationWith.YoutubeMusic]: 'music.youtube.com', +} + +// Mapping: which domain does each implementation integrate with? +export const ImplIntegratesWith: Record = { + [IntegrationImpl.SpotifyClientCredentials]: IntegrationWith.Spotify, + [IntegrationImpl.YoutubeWebScraper]: IntegrationWith.YoutubeMusic, +} + +// Data used for the Spotify Client Credentials implementation. +export interface SpotifyClientCredentialsDetails { + clientId: string, +} +export interface SpotifyClientCredentialsSecretDetails { + clientSecret: string, +} + +// Data used for the Youtube Music Web Scraper implementation. +export interface YoutubeMusicWebScraperDetails { } +export interface YoutubeMusicWebScraperSecretDetails { } + +export type IntegrationDetails = + SpotifyClientCredentialsDetails | + YoutubeMusicWebScraperDetails; +export type IntegrationSecretDetails = + SpotifyClientCredentialsSecretDetails | + YoutubeMusicWebScraperSecretDetails; + +// Integration resource. +export interface PartialIntegrationData { + mbApi_typename: "integrationData", + + name?: string, // Identifies this instance in the UI + type?: IntegrationImpl, + details?: IntegrationDetails, // Any data needed to operate the integration. + secretDetails?: IntegrationSecretDetails, // Any data needed to only be stored in the back-end, to operate the integration. +} +export interface IntegrationData extends PartialIntegrationData { + name: string, + type: IntegrationImpl, + details: IntegrationDetails, +} +export interface IntegrationDataWithId extends IntegrationData { + id: number, +} +export interface IntegrationDataWithSecret extends IntegrationData { + secretDetails: IntegrationSecretDetails, +} +export function isPartialIntegrationData(q: any): q is PartialIntegrationData { + return q.mbApi_typename && q.mbApi_typename === "integrationData"; +} +export function isIntegrationData(q: any): q is IntegrationData { + return isPartialIntegrationData(q) && + "name" in q && + "type" in q && + "details" in q; +} +export function isIntegrationDataWithSecret(q: any): q is IntegrationDataWithSecret { + return isIntegrationData(q) && "secretDetails" in q; +} + +// User resource. +export interface User { + mbApi_typename: "user", + email: string, + password: string, +} \ No newline at end of file diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 55b1a6f..8b82c98 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -6,7 +6,7 @@ import QueryWindow from './windows/query/QueryWindow'; import ArtistWindow from './windows/artist/ArtistWindow'; import AlbumWindow from './windows/album/AlbumWindow'; import TagWindow from './windows/tag/TagWindow'; -import SongWindow from './windows/song/SongWindow'; +import SongWindow from './windows/track/TrackWindow'; import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow'; import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom'; import LoginWindow from './windows/login/LoginWindow'; diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index b08b28b..844bf9e 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -1,16 +1,16 @@ import React from 'react'; -import { ExternalStore, StoreURLIdentifiers } from '../../api'; +import { IntegrationWith, IntegrationUrls } from '../../api/api'; import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg'; import { ReactComponent as YoutubeMusicIcon } from '../../assets/youtubemusic_icon.svg'; export interface IProps { - whichStore: ExternalStore, + whichStore: IntegrationWith, } export function whichStore(url: string) { - return Object.keys(StoreURLIdentifiers).reduce((prev: string | undefined, cur: string) => { - if(url.includes(StoreURLIdentifiers[cur as ExternalStore])) { + return Object.keys(IntegrationUrls).reduce((prev: string | undefined, cur: string) => { + if(url.includes(IntegrationUrls[cur as IntegrationWith])) { return cur; } return prev; @@ -24,11 +24,11 @@ export default function StoreLinkIcon(props: any) { { height: '40px', width: '40px' } : style; switch (whichStore) { - case ExternalStore.GooglePlayMusic: + case IntegrationWith.GooglePlayMusic: return ; - case ExternalStore.Spotify: + case IntegrationWith.Spotify: return ; - case ExternalStore.YoutubeMusic: + case IntegrationWith.YoutubeMusic: return ; default: throw new Error("Unknown external store: " + whichStore) diff --git a/client/src/components/querybuilder/QBAddElemMenu.tsx b/client/src/components/querybuilder/QBAddElemMenu.tsx index 1aa2b1d..8e0118f 100644 --- a/client/src/components/querybuilder/QBAddElemMenu.tsx +++ b/client/src/components/querybuilder/QBAddElemMenu.tsx @@ -112,16 +112,16 @@ export function QBAddElemMenu(props: MenuProps) { > New query element { onClose(); props.onCreateQuery({ - a: QueryLeafBy.SongTitle, + a: QueryLeafBy.TrackName, leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, b: s }); diff --git a/client/src/components/querybuilder/QBLeafElem.tsx b/client/src/components/querybuilder/QBLeafElem.tsx index dad3d03..f385fb9 100644 --- a/client/src/components/querybuilder/QBLeafElem.tsx +++ b/client/src/components/querybuilder/QBLeafElem.tsx @@ -138,14 +138,14 @@ export function QBLeafElem(props: IProps) { {...props} extraElements={extraElements} /> - } if (e.a === QueryLeafBy.SongTitle && + } if (e.a === QueryLeafBy.TrackName && e.leafOp === QueryLeafOp.Equals && typeof e.b == "string") { return - } else if (e.a === QueryLeafBy.SongTitle && + } else if (e.a === QueryLeafBy.TrackName && e.leafOp === QueryLeafOp.Like && typeof e.b == "string") { return Promise, getAlbums: (filter: string) => Promise, - getSongTitles: (filter: string) => Promise, + getTrackNames: (filter: string) => Promise, getTags: () => Promise, } diff --git a/client/src/components/tables/ResultsTable.tsx b/client/src/components/tables/ResultsTable.tsx index e79fb37..f581bdf 100644 --- a/client/src/components/tables/ResultsTable.tsx +++ b/client/src/components/tables/ResultsTable.tsx @@ -3,20 +3,20 @@ import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyle import stringifyList from '../../lib/stringifyList'; import { useHistory } from 'react-router'; -export interface SongGetters { - getTitle: (song: any) => string, - getId: (song: any) => number, - getArtistNames: (song: any) => string[], - getArtistIds: (song: any) => number[], - getAlbumNames: (song: any) => string[], - getAlbumIds: (song: any) => number[], - getTagNames: (song: any) => string[][], // Each tag is represented as a series of strings. - getTagIds: (song: any) => number[][], // Each tag is represented as a series of ids. +export interface TrackGetters { + getTitle: (track: any) => string, + getId: (track: any) => number, + getArtistNames: (track: any) => string[], + getArtistIds: (track: any) => number[], + getAlbumNames: (track: any) => string[], + getAlbumIds: (track: any) => number[], + 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. } -export default function SongTable(props: { - songs: any[], - songGetters: SongGetters, +export default function TrackTable(props: { + tracks: any[], + trackGetters: TrackGetters, }) { const history = useHistory(); @@ -44,17 +44,17 @@ export default function SongTable(props: { - {props.songs.map((song: any) => { - const title = props.songGetters.getTitle(song); + {props.tracks.map((track: any) => { + const title = props.trackGetters.getTitle(track); // TODO: display artists and albums separately! - const artistNames = props.songGetters.getArtistNames(song); + const artistNames = props.trackGetters.getArtistNames(track); const artist = stringifyList(artistNames); - const mainArtistId = props.songGetters.getArtistIds(song)[0]; - const albumNames = props.songGetters.getAlbumNames(song); + const mainArtistId = props.trackGetters.getArtistIds(track)[0]; + const albumNames = props.trackGetters.getAlbumNames(track); const album = stringifyList(albumNames); - const mainAlbumId = props.songGetters.getAlbumIds(song)[0]; - const songId = props.songGetters.getId(song); - const tagIds = props.songGetters.getTagIds(song); + const mainAlbumId = props.trackGetters.getAlbumIds(track)[0]; + const trackId = props.trackGetters.getId(track); + const tagIds = props.trackGetters.getTagIds(track); const onClickArtist = () => { history.push('/artist/' + mainArtistId); @@ -64,15 +64,15 @@ export default function SongTable(props: { history.push('/album/' + mainAlbumId); } - const onClickSong = () => { - history.push('/song/' + songId); + const onClickTrack = () => { + history.push('/track/' + trackId); } const onClickTag = (id: number, name: string) => { history.push('/tag/' + id); } - const tags = props.songGetters.getTagNames(song).map((tag: string[], i: number) => { + const tags = props.trackGetters.getTagNames(track).map((tag: string[], i: number) => { const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => { return (idx === 0) ? e : " / " + e; }) @@ -100,7 +100,7 @@ export default function SongTable(props: { } return - {title} + {title} {artist} {album} diff --git a/client/src/components/windows/Windows.tsx b/client/src/components/windows/Windows.tsx index 6d5a55b..27d2e8b 100644 --- a/client/src/components/windows/Windows.tsx +++ b/client/src/components/windows/Windows.tsx @@ -7,10 +7,10 @@ import AlbumIcon from '@material-ui/icons/Album'; import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import LoyaltyIcon from '@material-ui/icons/Loyalty'; -import SongWindow, { SongWindowReducer } from './song/SongWindow'; +import TrackWindow, { TrackWindowReducer } from './track/TrackWindow'; import AlbumWindow, { AlbumWindowReducer } from './album/AlbumWindow'; import TagWindow, { TagWindowReducer } from './tag/TagWindow'; -import { songGetters } from '../../lib/songGetters'; +import { trackGetters } from '../../lib/trackGetters'; import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow'; import { RegisterWindowReducer } from './register/RegisterWindow'; import { LoginWindowReducer } from './login/LoginWindow'; @@ -21,7 +21,7 @@ export enum WindowType { Artist = "Artist", Album = "Album", Tag = "Tag", - Song = "Song", + Track = "Track", ManageTags = "ManageTags", Login = "Login", Register = "Register", @@ -36,7 +36,7 @@ export const newWindowReducer = { [WindowType.Query]: QueryWindowReducer, [WindowType.Artist]: ArtistWindowReducer, [WindowType.Album]: AlbumWindowReducer, - [WindowType.Song]: SongWindowReducer, + [WindowType.Track]: TrackWindowReducer, [WindowType.Tag]: TagWindowReducer, [WindowType.ManageTags]: ManageTagsWindowReducer, [WindowType.Login]: LoginWindowReducer, @@ -59,8 +59,8 @@ export const newWindowState = { id: 1, metadata: null, pendingChanges: null, - songGetters: songGetters, - songsByArtist: null, + trackGetters: trackGetters, + tracksByArtist: null, } }, [WindowType.Album]: () => { @@ -68,11 +68,11 @@ export const newWindowState = { id: 1, metadata: null, pendingChanges: null, - songGetters: songGetters, - songsOnAlbum: null, + trackGetters: trackGetters, + tracksOnAlbum: null, } }, - [WindowType.Song]: () => { + [WindowType.Track]: () => { return { id: 1, metadata: null, @@ -84,8 +84,8 @@ export const newWindowState = { id: 1, metadata: null, pendingChanges: null, - songGetters: songGetters, - songsWithTag: null, + trackGetters: trackGetters, + tracksWithTag: null, } }, [WindowType.ManageTags]: () => { diff --git a/client/src/components/windows/album/AlbumWindow.tsx b/client/src/components/windows/album/AlbumWindow.tsx index 64bfd04..7c4535c 100644 --- a/client/src/components/windows/album/AlbumWindow.tsx +++ b/client/src/components/windows/album/AlbumWindow.tsx @@ -1,35 +1,35 @@ import React, { useEffect, useState, useReducer } from 'react'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core'; import AlbumIcon from '@material-ui/icons/Album'; -import * as serverApi from '../../../api'; +import * as serverApi from '../../../api/api'; import { WindowState } from '../Windows'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import EditableText from '../../common/EditableText'; import SubmitChangesButton from '../../common/SubmitChangesButton'; -import SongTable, { SongGetters } from '../../tables/ResultsTable'; +import TrackTable, { TrackGetters } from '../../tables/ResultsTable'; import { modifyAlbum } from '../../../lib/saveChanges'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { queryAlbums, querySongs } from '../../../lib/backend/queries'; -import { songGetters } from '../../../lib/songGetters'; +import { queryAlbums, queryTracks } from '../../../lib/backend/queries'; +import { trackGetters } from '../../../lib/trackGetters'; import { useParams } from 'react-router'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { useAuth } from '../../../lib/useAuth'; -export type AlbumMetadata = serverApi.AlbumDetails; -export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest; +export type AlbumMetadata = serverApi.AlbumWithId; +export type AlbumMetadataChanges = serverApi.PatchAlbumRequest; export interface AlbumWindowState extends WindowState { id: number, metadata: AlbumMetadata | null, pendingChanges: AlbumMetadataChanges | null, - songsOnAlbum: any[] | null, - songGetters: SongGetters, + tracksOnAlbum: any[] | null, + trackGetters: TrackGetters, } export enum AlbumWindowStateActions { SetMetadata = "SetMetadata", SetPendingChanges = "SetPendingChanges", - SetSongs = "SetSongs", + SetTracks = "SetTracks", Reload = "Reload", } @@ -39,10 +39,10 @@ export function AlbumWindowReducer(state: AlbumWindowState, action: any) { return { ...state, metadata: action.value } case AlbumWindowStateActions.SetPendingChanges: return { ...state, pendingChanges: action.value } - case AlbumWindowStateActions.SetSongs: - return { ...state, songsOnAlbum: action.value } + case AlbumWindowStateActions.SetTracks: + return { ...state, tracksOnAlbum: action.value } case AlbumWindowStateActions.Reload: - return { ...state, metadata: null, pendingChanges: null, songsOnAlbum: null } + return { ...state, metadata: null, pendingChanges: null, tracksOnAlbum: null } default: throw new Error("Unimplemented AlbumWindow state update.") } @@ -65,8 +65,8 @@ export default function AlbumWindow(props: {}) { id: parseInt(id), metadata: null, pendingChanges: null, - songGetters: songGetters, - songsOnAlbum: null, + trackGetters: trackGetters, + tracksOnAlbum: null, }); return @@ -76,7 +76,7 @@ export function AlbumWindowControlled(props: { state: AlbumWindowState, dispatch: (action: any) => void, }) { - let { id: albumId, metadata, pendingChanges, songsOnAlbum } = props.state; + let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state; let { dispatch } = props; let auth = useAuth(); @@ -92,12 +92,12 @@ export function AlbumWindowControlled(props: { .catch((e: any) => { handleNotLoggedIn(auth, e) }) }, [albumId, dispatch]); - // Effect to get the album's songs. + // Effect to get the album's tracks. useEffect(() => { - if (songsOnAlbum) { return; } + if (tracksOnAlbum) { return; } (async () => { - const songs = await querySongs( + const tracks = await queryTracks( { a: QueryLeafBy.AlbumId, b: albumId, @@ -106,11 +106,11 @@ export function AlbumWindowControlled(props: { ) .catch((e: any) => { handleNotLoggedIn(auth, e) }); dispatch({ - type: AlbumWindowStateActions.SetSongs, - value: songs, + type: AlbumWindowStateActions.SetTracks, + value: tracks, }); })(); - }, [songsOnAlbum, albumId, dispatch]); + }, [tracksOnAlbum, albumId, dispatch]); const [editingName, setEditingName] = useState(null); const name = { setApplying(true); - modifyAlbum(props.state.id, pendingChanges || {}) + modifyAlbum(props.state.id, pendingChanges || { mbApi_typename: 'album' }) .then(() => { setApplying(false); props.dispatch({ @@ -194,13 +194,13 @@ export function AlbumWindowControlled(props: { width="80%" > - Songs in this album in your library: + Tracks in this album in your library: - {props.state.songsOnAlbum && } - {!props.state.songsOnAlbum && } + {!props.state.tracksOnAlbum && } } \ No newline at end of file diff --git a/client/src/components/windows/artist/ArtistWindow.tsx b/client/src/components/windows/artist/ArtistWindow.tsx index 582ead2..0b49e06 100644 --- a/client/src/components/windows/artist/ArtistWindow.tsx +++ b/client/src/components/windows/artist/ArtistWindow.tsx @@ -1,35 +1,35 @@ import React, { useEffect, useState, useReducer } from 'react'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core'; import PersonIcon from '@material-ui/icons/Person'; -import * as serverApi from '../../../api'; +import * as serverApi from '../../../api/api'; import { WindowState } from '../Windows'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import EditableText from '../../common/EditableText'; import SubmitChangesButton from '../../common/SubmitChangesButton'; -import SongTable, { SongGetters } from '../../tables/ResultsTable'; +import TrackTable, { TrackGetters } from '../../tables/ResultsTable'; import { modifyArtist } from '../../../lib/saveChanges'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { queryArtists, querySongs } from '../../../lib/backend/queries'; -import { songGetters } from '../../../lib/songGetters'; +import { queryArtists, queryTracks } from '../../../lib/backend/queries'; +import { trackGetters } from '../../../lib/trackGetters'; import { useParams } from 'react-router'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { useAuth } from '../../../lib/useAuth'; -export type ArtistMetadata = serverApi.ArtistDetails; -export type ArtistMetadataChanges = serverApi.ModifyArtistRequest; +export type ArtistMetadata = serverApi.ArtistWithId; +export type ArtistMetadataChanges = serverApi.PatchArtistRequest; export interface ArtistWindowState extends WindowState { id: number, metadata: ArtistMetadata | null, pendingChanges: ArtistMetadataChanges | null, - songsByArtist: any[] | null, - songGetters: SongGetters, + tracksByArtist: any[] | null, + trackGetters: TrackGetters, } export enum ArtistWindowStateActions { SetMetadata = "SetMetadata", SetPendingChanges = "SetPendingChanges", - SetSongs = "SetSongs", + SetTracks = "SetTracks", Reload = "Reload", } @@ -39,10 +39,10 @@ export function ArtistWindowReducer(state: ArtistWindowState, action: any) { return { ...state, metadata: action.value } case ArtistWindowStateActions.SetPendingChanges: return { ...state, pendingChanges: action.value } - case ArtistWindowStateActions.SetSongs: - return { ...state, songsByArtist: action.value } + case ArtistWindowStateActions.SetTracks: + return { ...state, tracksByArtist: action.value } case ArtistWindowStateActions.Reload: - return { ...state, metadata: null, pendingChanges: null, songsByArtist: null } + return { ...state, metadata: null, pendingChanges: null, tracksByArtist: null } default: throw new Error("Unimplemented ArtistWindow state update.") } @@ -70,8 +70,8 @@ export default function ArtistWindow(props: {}) { id: parseInt(id), metadata: null, pendingChanges: null, - songGetters: songGetters, - songsByArtist: null, + trackGetters: trackGetters, + tracksByArtist: null, }); return @@ -81,7 +81,7 @@ export function ArtistWindowControlled(props: { state: ArtistWindowState, dispatch: (action: any) => void, }) { - let { metadata, id: artistId, pendingChanges, songsByArtist } = props.state; + let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state; let { dispatch } = props; let auth = useAuth(); @@ -97,12 +97,12 @@ export function ArtistWindowControlled(props: { .catch((e: any) => { handleNotLoggedIn(auth, e) }) }, [artistId, dispatch]); - // Effect to get the artist's songs. + // Effect to get the artist's tracks. useEffect(() => { - if (songsByArtist) { return; } + if (tracksByArtist) { return; } (async () => { - const songs = await querySongs( + const tracks = await queryTracks( { a: QueryLeafBy.ArtistId, b: artistId, @@ -111,11 +111,11 @@ export function ArtistWindowControlled(props: { ) .catch((e: any) => { handleNotLoggedIn(auth, e) }); dispatch({ - type: ArtistWindowStateActions.SetSongs, - value: songs, + type: ArtistWindowStateActions.SetTracks, + value: tracks, }); })(); - }, [songsByArtist, dispatch, artistId]); + }, [tracksByArtist, dispatch, artistId]); const [editingName, setEditingName] = useState(null); const name = { setApplying(true); - modifyArtist(props.state.id, pendingChanges || {}) + modifyArtist(props.state.id, pendingChanges || { mbApi_typename: 'artist' }) .then(() => { setApplying(false); props.dispatch({ @@ -199,13 +199,13 @@ export function ArtistWindowControlled(props: { width="80%" > - Songs by this artist in your library: + Tracks by this artist in your library: - {props.state.songsByArtist && } - {!props.state.songsByArtist && } + {!props.state.tracksByArtist && } } \ 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 d00c15c..ba9e8dd 100644 --- a/client/src/components/windows/manage_links/BatchLinkDialog.tsx +++ b/client/src/components/windows/manage_links/BatchLinkDialog.tsx @@ -3,15 +3,15 @@ import { Box, Button, Checkbox, createStyles, Dialog, DialogActions, DialogConte import StoreLinkIcon from '../../common/StoreLinkIcon'; import { $enum } from 'ts-enum-util'; import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations'; -import { ExternalStore, IntegrationStores, IntegrationType, ItemType, QueryResponseType, StoreURLIdentifiers } from '../../../api'; +import { IntegrationWith, ImplIntegratesWith, IntegrationImpl, ResourceType, QueryResponseType, IntegrationUrls } from '../../../api/api'; import { start } from 'repl'; import { QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot } from '../../../lib/query/Query'; -import { queryAlbums, queryArtists, queryItems, querySongs } from '../../../lib/backend/queries'; +import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries'; import asyncPool from "tiny-async-pool"; -import { getSong } from '../../../lib/backend/songs'; +import { getTrack } from '../../../lib/backend/tracks'; import { getAlbum } from '../../../lib/backend/albums'; import { getArtist } from '../../../lib/backend/artists'; -import { modifyAlbum, modifyArtist, modifySong } from '../../../lib/saveChanges'; +import { modifyAlbum, modifyArtist, modifyTrack } from '../../../lib/saveChanges'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -29,10 +29,10 @@ enum BatchJobState { } interface Task { - itemType: ItemType, + itemType: ResourceType, itemId: number, integrationId: number, - store: ExternalStore, + store: IntegrationWith, } interface BatchJobStatus { @@ -44,33 +44,33 @@ interface BatchJobStatus { async function makeTasks( integration: IntegrationState, - linkSongs: boolean, + linkTracks: boolean, linkArtists: boolean, linkAlbums: boolean, addTaskCb: (t: Task) => void, ) { let whichProp: any = { - [ItemType.Song]: QueryLeafBy.SongStoreLinks, - [ItemType.Artist]: QueryLeafBy.ArtistStoreLinks, - [ItemType.Album]: QueryLeafBy.AlbumStoreLinks, + [ResourceType.Track]: QueryLeafBy.TrackStoreLinks, + [ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks, + [ResourceType.Album]: QueryLeafBy.AlbumStoreLinks, } let whichElem: any = { - [ItemType.Song]: 'songs', - [ItemType.Artist]: 'artists', - [ItemType.Album]: 'albums', + [ResourceType.Track]: 'tracks', + [ResourceType.Artist]: 'artists', + [ResourceType.Album]: 'albums', } let maybeStore = integration.integration.providesStoreLink(); if (!maybeStore) { return; } - let store = maybeStore as ExternalStore; - let doForType = async (type: ItemType) => { + let store = maybeStore as IntegrationWith; + let doForType = async (type: ResourceType) => { let ids: number[] = ((await queryItems( [type], queryNot({ a: whichProp[type], leafOp: QueryLeafOp.Like, - b: `%${StoreURLIdentifiers[store]}%`, + b: `%${IntegrationUrls[store]}%`, }), undefined, undefined, @@ -86,15 +86,15 @@ async function makeTasks( }) } var promises: Promise[] = []; - if (linkSongs) { promises.push(doForType(ItemType.Song)); } - if (linkArtists) { promises.push(doForType(ItemType.Artist)); } - if (linkAlbums) { promises.push(doForType(ItemType.Album)); } + if (linkTracks) { promises.push(doForType(ResourceType.Track)); } + if (linkArtists) { promises.push(doForType(ResourceType.Artist)); } + if (linkAlbums) { promises.push(doForType(ResourceType.Album)); } console.log("Awaiting answer...") await Promise.all(promises); } async function doLinking( - toLink: { integrationId: number, songs: boolean, artists: boolean, albums: boolean }[], + toLink: { integrationId: number, tracks: boolean, artists: boolean, albums: boolean }[], setStatus: any, integrations: IntegrationState[], ) { @@ -114,13 +114,13 @@ async function doLinking( var tasks: Task[] = []; let collectionPromises = toLink.map((v: any) => { - let { integrationId, songs, artists, albums } = v; + let { integrationId, tracks, artists, albums } = v; let integration = integrations.find((i: IntegrationState) => i.id === integrationId); if (!integration) { return; } console.log('integration collect:', integration) return makeTasks( integration, - songs, + tracks, artists, albums, (t: Task) => { tasks.push(t) } @@ -160,28 +160,28 @@ async function doLinking( console.log('integration search:', integration) let _integration = integration as IntegrationState; let searchFuncs: any = { - [ItemType.Song]: (q: any, l: any) => { return _integration.integration.searchSong(q, l) }, - [ItemType.Album]: (q: any, l: any) => { return _integration.integration.searchAlbum(q, l) }, - [ItemType.Artist]: (q: any, l: any) => { return _integration.integration.searchArtist(q, l) }, + [ResourceType.Track]: (q: any, l: any) => { return _integration.integration.searchTrack(q, l) }, + [ResourceType.Album]: (q: any, l: any) => { return _integration.integration.searchAlbum(q, l) }, + [ResourceType.Artist]: (q: any, l: any) => { return _integration.integration.searchArtist(q, l) }, } // TODO include related items in search let getFuncs: any = { - [ItemType.Song]: getSong, - [ItemType.Album]: getAlbum, - [ItemType.Artist]: getArtist, + [ResourceType.Track]: getTrack, + [ResourceType.Album]: getAlbum, + [ResourceType.Artist]: getArtist, } let queryFuncs: any = { - [ItemType.Song]: (s: any) => `${s.title}` + + [ResourceType.Track]: (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}` + + [ResourceType.Album]: (s: any) => `${s.name}` + `${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}`, - [ItemType.Artist]: (s: any) => `${s.name}`, + [ResourceType.Artist]: (s: any) => `${s.name}`, } let modifyFuncs: any = { - [ItemType.Song]: modifySong, - [ItemType.Album]: modifyAlbum, - [ItemType.Artist]: modifyArtist, + [ResourceType.Track]: modifyTrack, + [ResourceType.Album]: modifyAlbum, + [ResourceType.Artist]: modifyArtist, } let item = await getFuncs[t.itemType](t.itemId); let query = queryFuncs[t.itemType](item); @@ -295,32 +295,32 @@ export default function BatchLinkDialog(props: { tasksFailed: 0, }); - var compatibleIntegrations: Record = { - [ExternalStore.GooglePlayMusic]: [], - [ExternalStore.YoutubeMusic]: [], - [ExternalStore.Spotify]: [], + var compatibleIntegrations: Record = { + [IntegrationWith.GooglePlayMusic]: [], + [IntegrationWith.YoutubeMusic]: [], + [IntegrationWith.Spotify]: [], }; - $enum(ExternalStore).getValues().forEach((store: ExternalStore) => { + $enum(IntegrationWith).getValues().forEach((store: IntegrationWith) => { compatibleIntegrations[store] = Array.isArray(integrations.state) ? - integrations.state.filter((i: IntegrationState) => IntegrationStores[i.properties.type] === store) + integrations.state.filter((i: IntegrationState) => ImplIntegratesWith[i.properties.type] === store) : []; }) interface StoreSettings { selectedIntegration: number | undefined, // Index into compatibleIntegrations linkArtists: boolean, - linkSongs: boolean, + linkTracks: boolean, linkAlbums: boolean, } - let [storeSettings, setStoreSettings] = useState>( - $enum(ExternalStore).getValues().reduce((prev: any, cur: ExternalStore) => { + let [storeSettings, setStoreSettings] = useState>( + $enum(IntegrationWith).getValues().reduce((prev: any, cur: IntegrationWith) => { return { ...prev, [cur]: { selectedIntegration: compatibleIntegrations[cur].length > 0 ? 0 : undefined, linkArtists: false, - linkSongs: false, + linkTracks: false, linkAlbums: false, } } @@ -352,7 +352,7 @@ export default function BatchLinkDialog(props: { Use Integration Which items - {$enum(ExternalStore).getValues().map((store: ExternalStore) => { + {$enum(IntegrationWith).getValues().map((store: IntegrationWith) => { let active = Boolean(compatibleIntegrations[store].length); return @@ -391,9 +391,9 @@ export default function BatchLinkDialog(props: { onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkAlbums: e.target.checked } } })} /> } label={Albums} /> setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkSongs: e.target.checked } } })} /> - } label={Songs} /> + setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkTracks: e.target.checked } } })} /> + } label={Tracks} /> ; })} @@ -412,13 +412,13 @@ export default function BatchLinkDialog(props: { onConfirm={() => { var toLink: any[] = []; Object.keys(storeSettings).forEach((store: string) => { - let s = store as ExternalStore; + let s = store as IntegrationWith; let active = Boolean(compatibleIntegrations[s].length); if (active && storeSettings[s].selectedIntegration !== undefined) { toLink.push({ integrationId: compatibleIntegrations[s][storeSettings[s].selectedIntegration || 0].id, - songs: storeSettings[s].linkSongs, + tracks: storeSettings[s].linkTracks, artists: storeSettings[s].linkArtists, albums: storeSettings[s].linkAlbums, }); diff --git a/client/src/components/windows/manage_links/LinksStatusWidget.tsx b/client/src/components/windows/manage_links/LinksStatusWidget.tsx index 9b0c016..59b28c6 100644 --- a/client/src/components/windows/manage_links/LinksStatusWidget.tsx +++ b/client/src/components/windows/manage_links/LinksStatusWidget.tsx @@ -1,7 +1,7 @@ import { Box, LinearProgress, Typography } from '@material-ui/core'; import React, { useCallback, useEffect, useReducer, useState } from 'react'; import { $enum } from 'ts-enum-util'; -import { ExternalStore, ItemType, QueryElemProperty, QueryResponseType, StoreURLIdentifiers } from '../../../api'; +import { IntegrationWith, ResourceType, QueryElemProperty, QueryResponseType, IntegrationUrls } from '../../../api/api'; import { queryItems } from '../../../lib/backend/queries'; import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import StoreLinkIcon from '../../common/StoreLinkIcon'; @@ -20,23 +20,23 @@ export default function LinksStatusWidget(props: { let [totalCounts, setTotalCounts] = useState(undefined); let [linkedCounts, setLinkedCounts] = useState>({}); - let queryStoreCount = async (store: ExternalStore, type: ItemType) => { + let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => { let whichProp: any = { - [ItemType.Song]: QueryLeafBy.SongStoreLinks, - [ItemType.Artist]: QueryLeafBy.ArtistStoreLinks, - [ItemType.Album]: QueryLeafBy.AlbumStoreLinks, + [ResourceType.Track]: QueryLeafBy.TrackStoreLinks, + [ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks, + [ResourceType.Album]: QueryLeafBy.AlbumStoreLinks, } let whichElem: any = { - [ItemType.Song]: 'songs', - [ItemType.Artist]: 'artists', - [ItemType.Album]: 'albums', + [ResourceType.Track]: 'songs', + [ResourceType.Artist]: 'artists', + [ResourceType.Album]: 'albums', } let r: any = await queryItems( [type], { a: whichProp[type], leafOp: QueryLeafOp.Like, - b: `%${StoreURLIdentifiers[store]}%`, + b: `%${IntegrationUrls[store]}%`, }, undefined, undefined, @@ -49,7 +49,7 @@ export default function LinksStatusWidget(props: { useEffect(() => { (async () => { let counts: any = await queryItems( - [ItemType.Song, ItemType.Artist, ItemType.Album], + [ResourceType.Track, ResourceType.Artist, ResourceType.Album], undefined, undefined, undefined, @@ -63,10 +63,10 @@ export default function LinksStatusWidget(props: { // Start retrieving counts per store useEffect(() => { (async () => { - let promises = $enum(ExternalStore).getValues().map((s: ExternalStore) => { - let songsPromise: Promise = queryStoreCount(s, ItemType.Song); - let albumsPromise: Promise = queryStoreCount(s, ItemType.Album); - let artistsPromise: Promise = queryStoreCount(s, ItemType.Artist); + let promises = $enum(IntegrationWith).getValues().map((s: IntegrationWith) => { + let songsPromise: Promise = queryStoreCount(s, ResourceType.Track); + let albumsPromise: Promise = queryStoreCount(s, ResourceType.Album); + let artistsPromise: Promise = queryStoreCount(s, ResourceType.Artist); let updatePromise = Promise.all([songsPromise, albumsPromise, artistsPromise]).then( (r: any[]) => { setLinkedCounts((prev: Record) => { @@ -89,13 +89,13 @@ export default function LinksStatusWidget(props: { )(); }, [setLinkedCounts]); - let storeReady = (s: ExternalStore) => { + let storeReady = (s: IntegrationWith) => { return s in linkedCounts; } return - {$enum(ExternalStore).getValues().map((s: ExternalStore) => { + {$enum(IntegrationWith).getValues().map((s: IntegrationWith) => { if (!totalCounts) { return <>; } if (!storeReady(s)) { return <>; } let tot = totalCounts; diff --git a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx index 5ce2e07..7b533d9 100644 --- a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx +++ b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx @@ -13,7 +13,7 @@ import Alert from '@material-ui/lab/Alert'; import { useHistory } from 'react-router'; import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request'; import { useAuth } from '../../../lib/useAuth'; -import * as serverApi from '../../../api'; +import * as serverApi from '../../../api/api'; var _ = require('lodash'); export interface ManageTagsWindowState extends WindowState { diff --git a/client/src/components/windows/manage_tags/TagChange.tsx b/client/src/components/windows/manage_tags/TagChange.tsx index b2e9c35..0b83b22 100644 --- a/client/src/components/windows/manage_tags/TagChange.tsx +++ b/client/src/components/windows/manage_tags/TagChange.tsx @@ -36,13 +36,14 @@ export async function submitTagChanges(changes: TagChange[]) { for (const change of changes) { // If string is of form "1", convert to ID number directly. // Otherwise, look it up in the table. - const parentId = change.parent ? getId(change.parent) : undefined; + const parentId = change.parent ? getId(change.parent) : null; const numericId = change.id ? getId(change.id) : undefined; const intoId = change.into ? getId(change.into) : undefined; switch (change.type) { case TagChangeType.Create: if (!change.name) { throw new Error("Cannot create tag without name"); } const { id } = await createTag({ + mbApi_typename: 'tag', name: change.name, parentId: parentId, }); @@ -53,6 +54,7 @@ export async function submitTagChanges(changes: TagChange[]) { await modifyTag( numericId, { + mbApi_typename: 'tag', parentId: parentId, }) break; @@ -61,6 +63,7 @@ export async function submitTagChanges(changes: TagChange[]) { await modifyTag( numericId, { + mbApi_typename: 'tag', name: change.name, }) break; diff --git a/client/src/components/windows/query/QueryWindow.tsx b/client/src/components/windows/query/QueryWindow.tsx index 9a428cc..779fa78 100644 --- a/client/src/components/windows/query/QueryWindow.tsx +++ b/client/src/components/windows/query/QueryWindow.tsx @@ -2,11 +2,11 @@ import React, { useEffect, useReducer, useCallback } from 'react'; import { Box, LinearProgress } from '@material-ui/core'; import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import QueryBuilder from '../../querybuilder/QueryBuilder'; -import SongTable from '../../tables/ResultsTable'; -import { songGetters } from '../../../lib/songGetters'; -import { queryArtists, querySongs, queryAlbums, queryTags } from '../../../lib/backend/queries'; +import TrackTable from '../../tables/ResultsTable'; +import { trackGetters } from '../../../lib/trackGetters'; +import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries'; import { WindowState } from '../Windows'; -import { QueryResponseType } from '../../../api'; +import { QueryResponseType } from '../../../api/api'; var _ = require('lodash'); export interface ResultsForQuery { @@ -52,17 +52,17 @@ async function getAlbumNames(filter: string) { return [...(new Set([...(albums.map((a: any) => a.name))]))]; } -async function getSongTitles(filter: string) { - const songs: any = await querySongs( +async function getTrackNames(filter: string) { + const tracks: any = await queryTracks( filter.length > 0 ? { - a: QueryLeafBy.SongTitle, + a: QueryLeafBy.TrackName, b: '%' + filter + '%', leafOp: QueryLeafOp.Like } : undefined, 0, -1, QueryResponseType.Details ); - return [...(new Set([...(songs.map((s: any) => s.title))]))]; + return [...(new Set([...(tracks.map((s: any) => s.title))]))]; } async function getTagItems(): Promise { @@ -117,7 +117,7 @@ export function QueryWindowControlled(props: { const showResults = (query && resultsFor && query === resultsFor.for) ? resultsFor.results : []; const doQuery = useCallback(async (_query: QueryElem) => { - const songs: any = await querySongs( + const tracks: any = await queryTracks( _query, 0, 100, //TODO: pagination @@ -127,7 +127,7 @@ export function QueryWindowControlled(props: { if (_.isEqual(query, _query)) { setResultsForQuery({ for: _query, - results: songs, + results: tracks, }) } }, [query, setResultsForQuery]); @@ -152,7 +152,7 @@ export function QueryWindowControlled(props: { onChangeEditing={setEditingQuery} requestFunctions={{ getArtists: getArtistNames, - getSongTitles: getSongTitles, + getTrackNames: getTrackNames, getAlbums: getAlbumNames, getTags: getTagItems, }} @@ -162,9 +162,9 @@ export function QueryWindowControlled(props: { m={1} width="80%" > - {loading && } diff --git a/client/src/components/windows/settings/IntegrationSettings.tsx b/client/src/components/windows/settings/IntegrationSettings.tsx index d8210f3..250981d 100644 --- a/client/src/components/windows/settings/IntegrationSettings.tsx +++ b/client/src/components/windows/settings/IntegrationSettings.tsx @@ -6,7 +6,7 @@ import EditIcon from '@material-ui/icons/Edit'; import CheckIcon from '@material-ui/icons/Check'; import DeleteIcon from '@material-ui/icons/Delete'; import ClearIcon from '@material-ui/icons/Clear'; -import * as serverApi from '../../../api'; +import * as serverApi from '../../../api/api'; import { v4 as genUuid } from 'uuid'; import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations'; import Alert from '@material-ui/lab/Alert'; @@ -59,7 +59,7 @@ function EditSpotifyClientCredentialsDetails(props: { // of an integration. function EditIntegration(props: { upstreamId?: number, - integration: serverApi.CreateIntegrationRequest, + integration: serverApi.PostIntegrationRequest, editing?: boolean, showSubmitButton?: boolean | "InProgress", showDeleteButton?: boolean | "InProgress", @@ -68,27 +68,27 @@ function EditIntegration(props: { showCancelButton?: boolean, flashMessage?: React.ReactFragment, isNew: boolean, - onChange?: (p: serverApi.CreateIntegrationRequest) => void, - onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, + onChange?: (p: serverApi.PostIntegrationRequest) => void, + onSubmit?: (p: serverApi.PostIntegrationRequest) => void, onDelete?: () => void, onEdit?: () => void, onTest?: () => void, onCancel?: () => void, }) { let IntegrationHeaders: Record = { - [serverApi.IntegrationType.SpotifyClientCredentials]: + [serverApi.IntegrationImpl.SpotifyClientCredentials]: - {new IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials](-1).getIcon({ + {new IntegrationClasses[serverApi.IntegrationImpl.SpotifyClientCredentials](-1).getIcon({ style: { height: '40px', width: '40px' } })} Spotify (using Client Credentials) , - [serverApi.IntegrationType.YoutubeWebScraper]: + [serverApi.IntegrationImpl.YoutubeWebScraper]: - {new IntegrationClasses[serverApi.IntegrationType.YoutubeWebScraper](-1).getIcon({ + {new IntegrationClasses[serverApi.IntegrationImpl.YoutubeWebScraper](-1).getIcon({ style: { height: '40px', width: '40px' } })} @@ -96,7 +96,7 @@ function EditIntegration(props: { , } let IntegrationDescription: Record = { - [serverApi.IntegrationType.SpotifyClientCredentials]: + [serverApi.IntegrationImpl.SpotifyClientCredentials]: This integration allows using the Spotify API to make requests that are tied to any specific user, such as searching items and retrieving item @@ -105,7 +105,7 @@ function EditIntegration(props: { and client secret. Once set, you will only be able to overwrite the secret here, not read it. , - [serverApi.IntegrationType.YoutubeWebScraper]: + [serverApi.IntegrationImpl.YoutubeWebScraper]: This integration allows using the public Youtube Music search page to scrape for music metadata.
@@ -137,7 +137,7 @@ function EditIntegration(props: { })} /> - {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && + {props.integration.type === serverApi.IntegrationImpl.SpotifyClientCredentials && void, - onAdd?: (type: serverApi.IntegrationType) => void, + onAdd?: (type: serverApi.IntegrationImpl) => void, }) { const pos = props.open && props.position ? { left: props.position[0], top: props.position[1] } @@ -222,13 +222,13 @@ function AddIntegrationMenu(props: { > { - props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); + props.onAdd && props.onAdd(serverApi.IntegrationImpl.SpotifyClientCredentials); props.onClose && props.onClose(); }} >Spotify via Client Credentials { - props.onAdd && props.onAdd(serverApi.IntegrationType.YoutubeWebScraper); + props.onAdd && props.onAdd(serverApi.IntegrationImpl.YoutubeWebScraper); props.onClose && props.onClose(); }} >Youtube Music Web Scraper @@ -240,7 +240,7 @@ function EditIntegrationDialog(props: { onClose?: () => void, upstreamId?: number, integration: IntegrationState, - onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, + onSubmit?: (p: serverApi.PostIntegrationRequest) => void, isNew: boolean, }) { let [editingIntegration, setEditingIntegration] = @@ -320,7 +320,7 @@ export default function IntegrationSettings(props: {}) { position={addMenuPos} open={addMenuPos !== null} onClose={onCloseAddMenu} - onAdd={(type: serverApi.IntegrationType) => { + onAdd={(type: serverApi.IntegrationImpl) => { let p = makeDefaultIntegrationProperties(type); setEditingState({ properties: p, @@ -334,7 +334,7 @@ export default function IntegrationSettings(props: {}) { onClose={() => { setEditingState(null); }} integration={editingState} isNew={editingState.id === -1} - onSubmit={(v: serverApi.CreateIntegrationRequest) => { + onSubmit={(v: serverApi.PostIntegrationRequest) => { if (editingState.id >= 0) { const id = editingState.id; setEditingState(null); diff --git a/client/src/components/windows/tag/TagWindow.tsx b/client/src/components/windows/tag/TagWindow.tsx index 5327eef..5f9a12f 100644 --- a/client/src/components/windows/tag/TagWindow.tsx +++ b/client/src/components/windows/tag/TagWindow.tsx @@ -1,38 +1,38 @@ import React, { useEffect, useState, useReducer } from 'react'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core'; import LocalOfferIcon from '@material-ui/icons/LocalOffer'; -import * as serverApi from '../../../api'; +import * as serverApi from '../../../api/api'; import { WindowState } from '../Windows'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import EditableText from '../../common/EditableText'; import SubmitChangesButton from '../../common/SubmitChangesButton'; -import SongTable, { SongGetters } from '../../tables/ResultsTable'; +import TrackTable, { TrackGetters } from '../../tables/ResultsTable'; import { modifyTag } from '../../../lib/backend/tags'; -import { queryTags, querySongs } from '../../../lib/backend/queries'; +import { queryTags, queryTracks } from '../../../lib/backend/queries'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { songGetters } from '../../../lib/songGetters'; +import { trackGetters } from '../../../lib/trackGetters'; import { useParams } from 'react-router'; -export interface FullTagMetadata extends serverApi.TagDetails { +export interface FullTagMetadata extends serverApi.TagWithId { fullName: string[], fullId: number[], } export type TagMetadata = FullTagMetadata; -export type TagMetadataChanges = serverApi.ModifyTagRequest; +export type TagMetadataChanges = serverApi.PatchTagRequest; export interface TagWindowState extends WindowState { id: number, metadata: TagMetadata | null, pendingChanges: TagMetadataChanges | null, - songsWithTag: any[] | null, - songGetters: SongGetters, + tracksWithTag: any[] | null, + trackGetters: TrackGetters, } export enum TagWindowStateActions { SetMetadata = "SetMetadata", SetPendingChanges = "SetPendingChanges", - SetSongs = "SetSongs", + SetTracks = "SetTracks", Reload = "Reload", } @@ -42,10 +42,10 @@ export function TagWindowReducer(state: TagWindowState, action: any) { return { ...state, metadata: action.value } case TagWindowStateActions.SetPendingChanges: return { ...state, pendingChanges: action.value } - case TagWindowStateActions.SetSongs: - return { ...state, songsWithTag: action.value } + case TagWindowStateActions.SetTracks: + return { ...state, tracksWithTag: action.value } case TagWindowStateActions.Reload: - return { ...state, metadata: null, songsWithTag: null } + return { ...state, metadata: null, tracksWithTag: null } default: throw new Error("Unimplemented TagWindow state update.") } @@ -81,8 +81,8 @@ export default function TagWindow(props: {}) { id: parseInt(id), metadata: null, pendingChanges: null, - songGetters: songGetters, - songsWithTag: null, + trackGetters: trackGetters, + tracksWithTag: null, }); return @@ -94,7 +94,7 @@ export function TagWindowControlled(props: { }) { let metadata = props.state.metadata; let pendingChanges = props.state.pendingChanges; - let { id: tagId, songsWithTag } = props.state; + let { id: tagId, tracksWithTag } = props.state; let dispatch = props.dispatch; // Effect to get the tag's metadata. @@ -108,12 +108,12 @@ export function TagWindowControlled(props: { }) }, [tagId, dispatch]); - // Effect to get the tag's songs. + // Effect to get the tag's tracks. useEffect(() => { - if (songsWithTag) { return; } + if (tracksWithTag) { return; } (async () => { - const songs: any = await querySongs( + const tracks: any = await queryTracks( { a: QueryLeafBy.TagId, b: tagId, @@ -121,11 +121,11 @@ export function TagWindowControlled(props: { }, 0, -1, serverApi.QueryResponseType.Details, ); dispatch({ - type: TagWindowStateActions.SetSongs, - value: songs, + type: TagWindowStateActions.SetTracks, + value: tracks, }); })(); - }, [songsWithTag, tagId, dispatch]); + }, [tracksWithTag, tagId, dispatch]); const [editingName, setEditingName] = useState(null); const name = - const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { - const store = whichStore(link); - return store && - - - - }); - const [applying, setApplying] = useState(false); const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 && { setApplying(true); - modifyTag(props.state.id, pendingChanges || {}) + modifyTag(props.state.id, pendingChanges || { mbApi_typename: 'tag' }) .then(() => { setApplying(false); props.dispatch({ @@ -201,11 +188,6 @@ export function TagWindowControlled(props: { {fullName} - - - {storeLinks} - - } - Songs with this tag in your library: + Tracks with this tag in your library: - {props.state.songsWithTag && } - {!props.state.songsWithTag && } + {!props.state.tracksWithTag && } } \ No newline at end of file diff --git a/client/src/components/windows/song/EditSongDialog.tsx b/client/src/components/windows/track/EditTrackDialog.tsx similarity index 85% rename from client/src/components/windows/song/EditSongDialog.tsx rename to client/src/components/windows/track/EditTrackDialog.tsx index 18c3b00..4a1488d 100644 --- a/client/src/components/windows/song/EditSongDialog.tsx +++ b/client/src/components/windows/track/EditTrackDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { AppBar, Box, Button, Dialog, DialogActions, Divider, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core"; -import { SongMetadata } from "./SongWindow"; +import { TrackMetadata } from "./TrackWindow"; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import CheckIcon from '@material-ui/icons/Check'; import SearchIcon from '@material-ui/icons/Search'; @@ -9,25 +9,25 @@ import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import DeleteIcon from '@material-ui/icons/Delete'; import { $enum } from "ts-enum-util"; import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations'; -import { IntegrationFeature, IntegrationSong } from '../../../lib/integration/Integration'; +import { IntegrationFeature, IntegrationTrack } from '../../../lib/integration/Integration'; import { TabPanel } from '@material-ui/lab'; import { v1 } from 'uuid'; -import { ExternalStore } from '../../../api'; +import { IntegrationWith } from '../../../api/api'; let _ = require('lodash') export function ProvideLinksWidget(props: { providers: IntegrationState[], - metadata: SongMetadata, - store: ExternalStore, + metadata: TrackMetadata, + store: IntegrationWith, onChange: (link: string | undefined) => void, }) { - let defaultQuery = `${props.metadata.title}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.albums && ` ${props.metadata.albums[0].name}`}`; + let defaultQuery = `${props.metadata.name}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.album && ` ${props.metadata.album.name}`}`; let [selectedProviderIdx, setSelectedProviderIdx] = useState( props.providers.length > 0 ? 0 : undefined ); let [query, setQuery] = useState(defaultQuery) - let [results, setResults] = useState(undefined); + let [results, setResults] = useState(undefined); let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ? props.providers[selectedProviderIdx] : undefined; @@ -63,14 +63,14 @@ export function ProvideLinksWidget(props: { /> { - selectedProvider?.integration.searchSong(query, 10) - .then((songs: IntegrationSong[]) => setResults(songs)) + selectedProvider?.integration.searchTrack(query, 10) + .then((tracks: IntegrationTrack[]) => setResults(tracks)) }} > {results && results.length > 0 && Suggestions:} props.onChange(e.target.value)}> - {results && results.map((result: IntegrationSong, idx: number) => { + {results && results.map((result: IntegrationTrack, idx: number) => { let pretty = `"${result.title}" ${result.artist && ` by ${result.artist.name}`} ${result.album && ` (${result.album.name})`}`; @@ -92,15 +92,15 @@ export function ProvideLinksWidget(props: { } export function ExternalLinksEditor(props: { - metadata: SongMetadata, - original: SongMetadata, - onChange: (v: SongMetadata) => void, + metadata: TrackMetadata, + original: TrackMetadata, + onChange: (v: TrackMetadata) => void, }) { let [selectedIdx, setSelectedIdx] = useState(0); let integrations = useIntegrations(); - let getLinksSet = (metadata: SongMetadata) => { - return $enum(ExternalStore).getValues().reduce((prev: any, store: string) => { + let getLinksSet = (metadata: TrackMetadata) => { + return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => { var maybeLink: string | null = null; metadata.storeLinks && metadata.storeLinks.forEach((link: string) => { if (whichStore(link) === store) { @@ -117,11 +117,11 @@ export function ExternalLinksEditor(props: { let linksSet: Record = getLinksSet(props.metadata); let originalLinksSet: Record = getLinksSet(props.original); - let store = $enum(ExternalStore).getValues()[selectedIdx]; + let store = $enum(IntegrationWith).getValues()[selectedIdx]; let providers: IntegrationState[] = Array.isArray(integrations.state) ? integrations.state.filter( (iState: IntegrationState) => ( - iState.integration.getFeatures().includes(IntegrationFeature.SearchSong) && + iState.integration.getFeatures().includes(IntegrationFeature.SearchTrack) && iState.integration.providesStoreLink() === store ) ) : []; @@ -130,7 +130,7 @@ export function ExternalLinksEditor(props: { - {$enum(ExternalStore).getValues().map((store: string, idx: number) => { + {$enum(IntegrationWith).getValues().map((store: string, idx: number) => { let maybeLink = linksSet[store]; let color: string | undefined = (linksSet[store] && !originalLinksSet[store]) ? "lightgreen" : @@ -190,19 +190,19 @@ export function ExternalLinksEditor(props: { } -export default function EditSongDialog(props: { +export default function EditTrackDialog(props: { open: boolean, onClose: () => void, - onSubmit: (v: SongMetadata) => void, + onSubmit: (v: TrackMetadata) => void, id: number, - metadata: SongMetadata, + metadata: TrackMetadata, }) { - enum EditSongTabs { + enum EditTrackTabs { Details = 0, ExternalLinks, } - let [editingMetadata, setEditingMetadata] = useState(props.metadata); + let [editingMetadata, setEditingMetadata] = useState(props.metadata); return setEditingMetadata(v)} + onChange={(v: TrackMetadata) => setEditingMetadata(v)} /> {!_.isEqual(editingMetadata, props.metadata) && diff --git a/client/src/components/windows/song/SongWindow.tsx b/client/src/components/windows/track/TrackWindow.tsx similarity index 67% rename from client/src/components/windows/song/SongWindow.tsx rename to client/src/components/windows/track/TrackWindow.tsx index 832aca0..e911b80 100644 --- a/client/src/components/windows/song/SongWindow.tsx +++ b/client/src/components/windows/track/TrackWindow.tsx @@ -3,45 +3,45 @@ import { Box, Typography, IconButton } from '@material-ui/core'; import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import PersonIcon from '@material-ui/icons/Person'; import AlbumIcon from '@material-ui/icons/Album'; -import * as serverApi from '../../../api'; +import * as serverApi from '../../../api/api'; import { WindowState } from '../Windows'; import { ArtistMetadata } from '../artist/ArtistWindow'; import { AlbumMetadata } from '../album/AlbumWindow'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { querySongs } from '../../../lib/backend/queries'; +import { queryTracks } from '../../../lib/backend/queries'; import { useParams } from 'react-router'; -import EditSongDialog from './EditSongDialog'; +import EditTrackDialog from './EditTrackDialog'; import EditIcon from '@material-ui/icons/Edit'; -import { modifySong } from '../../../lib/saveChanges'; +import { modifyTrack } from '../../../lib/saveChanges'; -export type SongMetadata = serverApi.SongDetails; +export type TrackMetadata = serverApi.TrackWithDetails; -export interface SongWindowState extends WindowState { +export interface TrackWindowState extends WindowState { id: number, - metadata: SongMetadata | null, + metadata: TrackMetadata | null, } -export enum SongWindowStateActions { +export enum TrackWindowStateActions { SetMetadata = "SetMetadata", Reload = "Reload", } -export function SongWindowReducer(state: SongWindowState, action: any) { +export function TrackWindowReducer(state: TrackWindowState, action: any) { switch (action.type) { - case SongWindowStateActions.SetMetadata: + case TrackWindowStateActions.SetMetadata: return { ...state, metadata: action.value } - case SongWindowStateActions.Reload: + case TrackWindowStateActions.Reload: return { ...state, metadata: null } default: - throw new Error("Unimplemented SongWindow state update.") + throw new Error("Unimplemented TrackWindow state update.") } } -export async function getSongMetadata(id: number) { - let response: any = await querySongs( +export async function getTrackMetadata(id: number) { + let response: any = await queryTracks( { - a: QueryLeafBy.SongId, + a: QueryLeafBy.TrackId, b: id, leafOp: QueryLeafOp.Equals, }, 0, 1, serverApi.QueryResponseType.Details @@ -49,37 +49,37 @@ export async function getSongMetadata(id: number) { return response[0]; } -export default function SongWindow(props: {}) { +export default function TrackWindow(props: {}) { const { id } = useParams<{ id: string }>(); - const [state, dispatch] = useReducer(SongWindowReducer, { + const [state, dispatch] = useReducer(TrackWindowReducer, { id: parseInt(id), metadata: null, }); - return + return } -export function SongWindowControlled(props: { - state: SongWindowState, +export function TrackWindowControlled(props: { + state: TrackWindowState, dispatch: (action: any) => void, }) { - let { metadata, id: songId } = props.state; + let { metadata, id: trackId } = props.state; let { dispatch } = props; let [editing, setEditing] = useState(false); useEffect(() => { if (metadata === null) { - getSongMetadata(songId) - .then((m: SongMetadata) => { + getTrackMetadata(trackId) + .then((m: TrackMetadata) => { dispatch({ - type: SongWindowStateActions.SetMetadata, + type: TrackWindowStateActions.SetMetadata, value: m }); }) } - }, [songId, dispatch, metadata]); + }, [trackId, dispatch, metadata]); - const title = {metadata?.title || "(Unknown title)"} + const title = {metadata?.name || "(Unknown title)"} const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => { return @@ -87,11 +87,9 @@ export function SongWindowControlled(props: { }); - const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => { - return - {album.name} - - }); + const album = metadata?.album && + {metadata?.album.name} + ; const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { const store = whichStore(link); @@ -134,7 +132,7 @@ export function SongWindowControlled(props: { - {albums} + {album} @@ -150,16 +148,16 @@ export function SongWindowControlled(props: { } - {metadata && { setEditing(false); }} - onSubmit={(v: serverApi.ModifySongRequest) => { - modifySong(songId, v) + onSubmit={(v: serverApi.PatchTrackRequest) => { + modifyTrack(trackId, v) .then(() => dispatch({ - type: SongWindowStateActions.Reload + type: TrackWindowStateActions.Reload })) }} - id={songId} + id={trackId} metadata={metadata} />} diff --git a/client/src/lib/backend/albums.tsx b/client/src/lib/backend/albums.tsx index ce983be..90d01db 100644 --- a/client/src/lib/backend/albums.tsx +++ b/client/src/lib/backend/albums.tsx @@ -1,8 +1,9 @@ -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; +import { GetAlbumResponse } from '../../api/api'; import backendRequest from './request'; -export async function getAlbum(id: number) { - const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.AlbumDetailsEndpoint.replace(':id', `${id}`)) +export async function getAlbum(id: number): Promise { + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.GetAlbumEndpoint.replace(':id', `${id}`)) if (!response.ok) { throw new Error("Response to album request not OK: " + JSON.stringify(response)); } diff --git a/client/src/lib/backend/artists.tsx b/client/src/lib/backend/artists.tsx index 097036c..b98fd9e 100644 --- a/client/src/lib/backend/artists.tsx +++ b/client/src/lib/backend/artists.tsx @@ -1,8 +1,9 @@ -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; +import { GetArtistResponse } from '../../api/api'; import backendRequest from './request'; -export async function getArtist(id: number) { - const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.ArtistDetailsEndpoint.replace(':id', `${id}`)) +export async function getArtist(id: number): Promise { + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.GetArtistEndpoint.replace(':id', `${id}`)) if (!response.ok) { throw new Error("Response to artist request not OK: " + JSON.stringify(response)); } diff --git a/client/src/lib/backend/integrations.tsx b/client/src/lib/backend/integrations.tsx index f4468ff..006382d 100644 --- a/client/src/lib/backend/integrations.tsx +++ b/client/src/lib/backend/integrations.tsx @@ -1,15 +1,16 @@ -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; +import { PutIntegrationResponse } from '../../api/api'; import { useAuth } from '../useAuth'; import backendRequest from './request'; -export async function createIntegration(details: serverApi.CreateIntegrationRequest) { +export async function createIntegration(details: serverApi.PostIntegrationRequest): Promise { const requestOpts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(details), }; - const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.PostIntegrationEndpoint, requestOpts) if (!response.ok) { throw new Error("Response to integration creation not OK: " + JSON.stringify(response)); } @@ -17,7 +18,7 @@ export async function createIntegration(details: serverApi.CreateIntegrationRequ return await response.json(); } -export async function modifyIntegration(id: number, details: serverApi.ModifyIntegrationRequest) { +export async function modifyIntegration(id: number, details: serverApi.PatchIntegrationRequest): Promise { const requestOpts = { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -25,16 +26,16 @@ export async function modifyIntegration(id: number, details: serverApi.ModifyInt }; const response = await backendRequest( - (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()), + (process.env.REACT_APP_BACKEND || "") + serverApi.PatchIntegrationEndpoint.replace(':id', id.toString()), requestOpts ); if (!response.ok) { - throw new Error("Response to integration modification not OK: " + JSON.stringify(response)); + throw new Error("Response to integration Patch not OK: " + JSON.stringify(response)); } } -export async function deleteIntegration(id: number) { +export async function deleteIntegration(id: number): Promise { const requestOpts = { method: 'DELETE', }; @@ -48,7 +49,7 @@ export async function deleteIntegration(id: number) { } } -export async function getIntegrations() { +export async function getIntegrations(): Promise { const requestOpts = { method: 'GET', }; diff --git a/client/src/lib/backend/queries.tsx b/client/src/lib/backend/queries.tsx index bc666c1..b94e4e6 100644 --- a/client/src/lib/backend/queries.tsx +++ b/client/src/lib/backend/queries.tsx @@ -1,41 +1,26 @@ -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; import { QueryElem, toApiQuery } from '../query/Query'; import backendRequest from './request'; export async function queryItems( - types: serverApi.ItemType[], + types: serverApi.ResourceType[], query: QueryElem | undefined, offset: number | undefined, limit: number | undefined, responseType: serverApi.QueryResponseType, -): Promise<{ - artists: serverApi.ArtistDetails[], - albums: serverApi.AlbumDetails[], - tags: serverApi.TagDetails[], - songs: serverApi.SongDetails[], -} | { - artists: number[], - albums: number[], - tags: number[], - songs: number[], -} | { - artists: number, - albums: number, - tags: number, - songs: number, -}> { +): Promise { console.log("Types:", types); var q: serverApi.QueryRequest = { query: query ? toApiQuery(query) : {}, offsetsLimits: { - 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, + artistOffset: (types.includes(serverApi.ResourceType.Artist)) ? (offset || 0) : undefined, + artistLimit: (types.includes(serverApi.ResourceType.Artist)) ? (limit || -1) : undefined, + albumOffset: (types.includes(serverApi.ResourceType.Album)) ? (offset || 0) : undefined, + albumLimit: (types.includes(serverApi.ResourceType.Album)) ? (limit || -1) : undefined, + trackOffset: (types.includes(serverApi.ResourceType.Track)) ? (offset || 0) : undefined, + trackLimit: (types.includes(serverApi.ResourceType.Track)) ? (limit || -1) : undefined, + tagOffset: (types.includes(serverApi.ResourceType.Tag)) ? (offset || 0) : undefined, + tagLimit: (types.includes(serverApi.ResourceType.Tag)) ? (limit || -1) : undefined, }, ordering: { orderBy: { @@ -66,36 +51,9 @@ export async function queryArtists( offset: number | undefined, limit: number | undefined, responseType: serverApi.QueryResponseType, -): Promise { - let r = await queryItems([serverApi.ItemType.Artist], query, offset, limit, responseType); +): Promise { + let r = await queryItems([serverApi.ResourceType.Artist], query, offset, limit, responseType); return r.artists; - - // var q: serverApi.QueryRequest = { - // query: query ? toApiQuery(query) : {}, - // offsetsLimits: { - // artistOffset: offset, - // artistLimit: limit, - // }, - // ordering: { - // orderBy: { - // type: serverApi.OrderByType.Name, - // }, - // ascending: true, - // }, - // responseType: responseType, - // }; - - // const requestOpts = { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(q), - // }; - - // return (async () => { - // const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) - // let json: any = await response.json(); - // return json.artists; - // })(); } export async function queryAlbums( @@ -103,73 +61,19 @@ export async function queryAlbums( offset: number | undefined, limit: number | undefined, responseType: serverApi.QueryResponseType, -): Promise { - let r = await queryItems([serverApi.ItemType.Album], query, offset, limit, responseType); +): Promise { + let r = await queryItems([serverApi.ResourceType.Album], query, offset, limit, responseType); return r.albums; - - // var q: serverApi.QueryRequest = { - // query: query ? toApiQuery(query) : {}, - // offsetsLimits: { - // albumOffset: offset, - // albumLimit: limit, - // }, - // ordering: { - // orderBy: { - // type: serverApi.OrderByType.Name, - // }, - // ascending: true, - // }, - // responseType: responseType, - // }; - - // const requestOpts = { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(q), - // }; - - // return (async () => { - // const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) - // let json: any = await response.json(); - // return json.albums; - // })(); } -export async function querySongs( +export async function queryTracks( query: QueryElem | undefined, offset: number | undefined, limit: number | undefined, responseType: serverApi.QueryResponseType, -): Promise { - let r = await queryItems([serverApi.ItemType.Song], query, offset, limit, responseType); - return r.songs; - - // var q: serverApi.QueryRequest = { - // query: query ? toApiQuery(query) : {}, - // offsetsLimits: { - // songOffset: offset, - // songLimit: limit, - // }, - // ordering: { - // orderBy: { - // type: serverApi.OrderByType.Name, - // }, - // ascending: true, - // }, - // responseType: responseType, - // }; - - // const requestOpts = { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(q), - // }; - - // return (async () => { - // const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) - // let json: any = await response.json(); - // return json.songs; - // })(); +): Promise { + let r = await queryItems([serverApi.ResourceType.Track], query, offset, limit, responseType); + return r.tracks; } export async function queryTags( @@ -177,54 +81,7 @@ export async function queryTags( offset: number | undefined, limit: number | undefined, responseType: serverApi.QueryResponseType, -): Promise { - let r = await queryItems([serverApi.ItemType.Tag], query, offset, limit, responseType); +): Promise { + let r = await queryItems([serverApi.ResourceType.Tag], query, offset, limit, responseType); return r.tags; - - // var q: serverApi.QueryRequest = { - // query: query ? toApiQuery(query) : {}, - // offsetsLimits: { - // tagOffset: offset, - // tagLimit: limit, - // }, - // ordering: { - // orderBy: { - // type: serverApi.OrderByType.Name, - // }, - // ascending: true, - // }, - // responseType: responseType, - // }; - - // const requestOpts = { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(q), - // }; - - // return (async () => { - // const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts); - // let json: any = await response.json(); - // const tags = json.tags; - - // // Organise the tags into a tree structure. - // // First, we put them in an indexed dict. - // const idxTags: Record = {}; - // tags.forEach((tag: any) => { - // idxTags[tag.tagId] = { - // ...tag, - // childIds: [], - // } - // }) - - // // Resolve children. - // tags.forEach((tag: any) => { - // if (tag.parentId && tag.parentId in idxTags) { - // idxTags[tag.parentId].childIds.push(tag.tagId); - // } - // }) - - // // Return the loose objects again. - // return Object.values(idxTags); - // })(); } \ No newline at end of file diff --git a/client/src/lib/backend/songs.tsx b/client/src/lib/backend/songs.tsx deleted file mode 100644 index 6fc8f0c..0000000 --- a/client/src/lib/backend/songs.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as serverApi from '../../api'; -import backendRequest from './request'; - -export async function getSong(id: number) { - const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.SongDetailsEndpoint.replace(':id', `${id}`)) - if (!response.ok) { - throw new Error("Response to song request not OK: " + JSON.stringify(response)); - } - return await response.json(); -} diff --git a/client/src/lib/backend/tags.tsx b/client/src/lib/backend/tags.tsx index 0c3ea72..838ba97 100644 --- a/client/src/lib/backend/tags.tsx +++ b/client/src/lib/backend/tags.tsx @@ -1,21 +1,21 @@ -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; import backendRequest from './request'; -export async function createTag(details: serverApi.CreateTagRequest) { +export async function createTag(details: serverApi.PostTagRequest) { const requestOpts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(details), }; - const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts) + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.PostTagEndpoint, requestOpts) if (!response.ok) { throw new Error("Response to tag creation not OK: " + JSON.stringify(response)); } return await response.json(); } -export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) { +export async function modifyTag(id: number, details: serverApi.PatchTagRequest) { const requestOpts = { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -23,7 +23,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) }; const response = await backendRequest( - (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()), + (process.env.REACT_APP_BACKEND || "") + serverApi.PatchTagEndpoint.replace(':id', id.toString()), requestOpts ); if (!response.ok) { diff --git a/client/src/lib/backend/tracks.tsx b/client/src/lib/backend/tracks.tsx new file mode 100644 index 0000000..6c60d7d --- /dev/null +++ b/client/src/lib/backend/tracks.tsx @@ -0,0 +1,10 @@ +import * as serverApi from '../../api/api'; +import backendRequest from './request'; + +export async function getTrack(id: number): Promise { + const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.GetTrackEndpoint.replace(':id', `${id}`)) + if (!response.ok) { + throw new Error("Response to track request not OK: " + JSON.stringify(response)); + } + return await response.json(); +} diff --git a/client/src/lib/integration/Integration.tsx b/client/src/lib/integration/Integration.tsx index fd82f82..b3a67c8 100644 --- a/client/src/lib/integration/Integration.tsx +++ b/client/src/lib/integration/Integration.tsx @@ -1,5 +1,5 @@ import React, { ReactFragment } from 'react'; -import { ExternalStore } from '../../api'; +import { IntegrationWith } from '../../api/api'; export interface IntegrationAlbum { name?: string, @@ -12,7 +12,7 @@ export interface IntegrationArtist { url?: string, // An URL to access the item externally. } -export interface IntegrationSong { +export interface IntegrationTrack { title?: string, album?: IntegrationAlbum, artist?: IntegrationArtist, @@ -24,10 +24,10 @@ export enum IntegrationFeature { Test = 0, // Used to get a bucket of songs (typically: the whole library) - GetSongs, + GetTracks, // Used to search items and get some amount of candidate results. - SearchSong, + SearchTrack, SearchAlbum, SearchArtist, } @@ -42,16 +42,16 @@ export default class Integration { // Common getFeatures(): IntegrationFeature[] { return []; } getIcon(props: any): ReactFragment { return <> } - providesStoreLink(): ExternalStore | null { return null; } + providesStoreLink(): IntegrationWith | null { return null; } // Requires feature: Test async test(testParams: any): Promise {} - // Requires feature: GetSongs - async getSongs(getSongsParams: any): Promise { return []; } + // Requires feature: GetTracks + async getTracks(getTracksParams: any): Promise { return []; } - // Requires feature: SearchSongs - async searchSong(query: string, limit: number): Promise { return []; } + // Requires feature: SearchTracks + async searchTrack(query: string, limit: number): Promise { return []; } // Requires feature: SearchAlbum async searchAlbum(query: string, limit: number): Promise { return []; } diff --git a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx index df2a05b..39246f1 100644 --- a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx +++ b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration'; +import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationTrack } from '../Integration'; import StoreLinkIcon from '../../../components/common/StoreLinkIcon'; -import { ExternalStore } from '../../../api'; +import { IntegrationWith } from '../../../api/api'; enum SearchType { - Song = 'track', + Track = 'track', Artist = 'artist', Album = 'album', }; @@ -20,18 +20,18 @@ export default class SpotifyClientCreds extends Integration { getFeatures(): IntegrationFeature[] { return [ IntegrationFeature.Test, - IntegrationFeature.SearchSong, + IntegrationFeature.SearchTrack, IntegrationFeature.SearchAlbum, IntegrationFeature.SearchArtist, ] } getIcon(props: any) { - return + return } providesStoreLink() { - return ExternalStore.Spotify; + return IntegrationWith.Spotify; } async test(testParams: {}) { @@ -44,8 +44,8 @@ export default class SpotifyClientCreds extends Integration { } } - async searchSong(query: string, limit: number): Promise { - return this.search(query, SearchType.Song, limit); + async searchTrack(query: string, limit: number): Promise { + return this.search(query, SearchType.Track, limit); } async searchAlbum(query: string, limit: number): Promise { return this.search(query, SearchType.Album, limit); @@ -55,7 +55,7 @@ export default class SpotifyClientCreds extends Integration { } async search(query: string, type: SearchType, limit: number): - Promise { + Promise { const response = await fetch( (process.env.REACT_APP_BACKEND || "") + `/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}`); @@ -69,8 +69,8 @@ export default class SpotifyClientCreds extends Integration { console.log("Response:", json); switch(type) { - case SearchType.Song: { - return json.tracks.items.map((r: any): IntegrationSong => { + case SearchType.Track: { + return json.tracks.items.map((r: any): IntegrationTrack => { return { title: r.name, url: r.external_urls.spotify, diff --git a/client/src/lib/integration/useIntegrations.tsx b/client/src/lib/integration/useIntegrations.tsx index 0960862..936e833 100644 --- a/client/src/lib/integration/useIntegrations.tsx +++ b/client/src/lib/integration/useIntegrations.tsx @@ -1,6 +1,6 @@ import React, { useState, useContext, createContext, useReducer, useEffect } from "react"; import Integration from "./Integration"; -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; import SpotifyClientCreds from "./spotify/SpotifyClientCreds"; import * as backend from "../backend/integrations"; import { handleNotLoggedIn, NotLoggedInError } from "../backend/request"; @@ -10,7 +10,7 @@ import YoutubeMusicWebScraper from "./youtubemusic/YoutubeMusicWebScraper"; export type IntegrationState = { id: number, integration: Integration, - properties: serverApi.CreateIntegrationRequest, + properties: serverApi.PostIntegrationRequest, }; export type IntegrationsState = IntegrationState[] | "Loading"; @@ -20,30 +20,32 @@ export function isIntegrationState(v: any): v is IntegrationState { export interface Integrations { state: IntegrationsState, - addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise, + addIntegration: (v: serverApi.PostIntegrationRequest) => Promise, deleteIntegration: (id: number) => Promise, - modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise, + modifyIntegration: (id: number, v: serverApi.PostIntegrationRequest) => Promise, updateFromUpstream: () => Promise, }; export const IntegrationClasses: Record = { - [serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds, - [serverApi.IntegrationType.YoutubeWebScraper]: YoutubeMusicWebScraper, + [serverApi.IntegrationImpl.SpotifyClientCredentials]: SpotifyClientCreds, + [serverApi.IntegrationImpl.YoutubeWebScraper]: YoutubeMusicWebScraper, } -export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType): - serverApi.CreateIntegrationRequest { +export function makeDefaultIntegrationProperties(type: serverApi.IntegrationImpl): + serverApi.PostIntegrationRequest { switch (type) { - case serverApi.IntegrationType.SpotifyClientCredentials: { + case serverApi.IntegrationImpl.SpotifyClientCredentials: { return { + mbApi_typename: 'integrationData', name: "Spotify App", type: type, details: { clientId: "" }, secretDetails: { clientSecret: "" }, } } - case serverApi.IntegrationType.YoutubeWebScraper: { + case serverApi.IntegrationImpl.YoutubeWebScraper: { return { + mbApi_typename: 'integrationData', name: "Youtube Music Web Scraper", type: type, details: {}, @@ -56,12 +58,12 @@ export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType } } -export function makeIntegration(p: serverApi.CreateIntegrationRequest, id: number) { +export function makeIntegration(p: serverApi.PostIntegrationRequest, id: number) { switch (p.type) { - case serverApi.IntegrationType.SpotifyClientCredentials: { + case serverApi.IntegrationImpl.SpotifyClientCredentials: { return new SpotifyClientCreds(id); } - case serverApi.IntegrationType.YoutubeWebScraper: { + case serverApi.IntegrationImpl.YoutubeWebScraper: { return new YoutubeMusicWebScraper(id); } default: { @@ -142,10 +144,10 @@ function useProvideIntegrations(): Integrations { .catch((e) => handleNotLoggedIn(auth, e)); } - let addIntegration = async (v: serverApi.CreateIntegrationRequest) => { + let addIntegration = async (v: serverApi.PostIntegrationRequest) => { const id = await backend.createIntegration(v).catch((e: any) => { handleNotLoggedIn(auth, e) }); await updateFromUpstream(); - return id; + return (id as serverApi.PostIntegrationResponse).id; } let deleteIntegration = async (id: number) => { @@ -153,7 +155,7 @@ function useProvideIntegrations(): Integrations { await updateFromUpstream(); } - let modifyIntegration = async (id: number, v: serverApi.CreateIntegrationRequest) => { + let modifyIntegration = async (id: number, v: serverApi.PostIntegrationRequest) => { await backend.modifyIntegration(id, v).catch((e: any) => { handleNotLoggedIn(auth, e) }); await updateFromUpstream(); } diff --git a/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx b/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx index 5737bd2..6bb7c2b 100644 --- a/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx +++ b/client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration'; +import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationTrack } from '../Integration'; import StoreLinkIcon from '../../../components/common/StoreLinkIcon'; -import { ExternalStore } from '../../../api'; +import { IntegrationWith } from '../../../api/api'; enum SearchType { - Song = 'track', + Track = 'track', Artist = 'artist', Album = 'album', }; @@ -33,18 +33,18 @@ export function extractInitialData(text: string): any | undefined { return json; } -export function parseSongs(initialData: any): IntegrationSong[] { +export function parseTracks(initialData: any): IntegrationTrack[] { try { var musicResponsiveListItemRenderers: any[] = []; - // Scrape for any "Song"-type items. + // Scrape for any "Track"-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 === "Song") { + .musicResponsiveListItemFlexColumnRenderer.text.runs[0].text === "Track") { musicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer); } }) @@ -55,7 +55,7 @@ export function parseSongs(initialData: any): IntegrationSong[] { let videoId = s.doubleTapCommand.watchEndpoint.videoId; let columns = s.flexColumns; - if (columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text !== "Song") { + if (columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text !== "Track") { throw new Error('song item doesnt match scraper expectation'); } let title = columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text; @@ -94,7 +94,7 @@ export function parseSongs(initialData: any): IntegrationSong[] { } } -export function parseArtists(initialData: any): IntegrationSong[] { +export function parseArtists(initialData: any): IntegrationTrack[] { try { var musicResponsiveListItemRenderers: any[] = []; @@ -132,7 +132,7 @@ export function parseArtists(initialData: any): IntegrationSong[] { } } -export function parseAlbums(initialData: any): IntegrationSong[] { +export function parseAlbums(initialData: any): IntegrationTrack[] { try { var musicResponsiveListItemRenderers: any[] = []; @@ -181,18 +181,18 @@ export default class YoutubeMusicWebScraper extends Integration { getFeatures(): IntegrationFeature[] { return [ IntegrationFeature.Test, - IntegrationFeature.SearchSong, + IntegrationFeature.SearchTrack, IntegrationFeature.SearchAlbum, IntegrationFeature.SearchArtist, ] } getIcon(props: any) { - return + return } providesStoreLink() { - return ExternalStore.YoutubeMusic; + return IntegrationWith.YoutubeMusic; } async test(testParams: {}) { @@ -201,20 +201,20 @@ export default class YoutubeMusicWebScraper extends Integration { `/integrations/${this.integrationId}/search?q=${encodeURIComponent('No One Knows Queens Of The Stone Age')}`); let text = await response.text(); - let songs = parseSongs(extractInitialData(text)); + let songs = parseTracks(extractInitialData(text)); 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."); } } - async searchSong(query: string, limit: number): Promise { + async searchTrack(query: string, limit: number): Promise { const response = await fetch( (process.env.REACT_APP_BACKEND || "") + `/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`); let text = await response.text(); - return parseSongs(extractInitialData(text)); + return parseTracks(extractInitialData(text)); } async searchAlbum(query: string, limit: number): Promise { const response = await fetch( diff --git a/client/src/lib/query/Query.tsx b/client/src/lib/query/Query.tsx index eda2cdb..ea94a47 100644 --- a/client/src/lib/query/Query.tsx +++ b/client/src/lib/query/Query.tsx @@ -1,4 +1,4 @@ -import * as serverApi from '../../api'; +import * as serverApi from '../../api/api'; export enum QueryLeafBy { ArtistName = 0, @@ -7,9 +7,9 @@ export enum QueryLeafBy { AlbumId, TagInfo, TagId, - SongTitle, - SongId, - SongStoreLinks, + TrackName, + TrackId, + TrackStoreLinks, ArtistStoreLinks, AlbumStoreLinks, } @@ -179,32 +179,32 @@ export function simplify(q: QueryElem | null): QueryElem | null { export function toApiQuery(q: QueryElem) : serverApi.Query { const propsMapping: any = { - [QueryLeafBy.SongTitle]: serverApi.QueryElemProperty.songTitle, + [QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName, [QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName, [QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName, [QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId, [QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId, [QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId, - [QueryLeafBy.SongId]: serverApi.QueryElemProperty.songId, - [QueryLeafBy.SongStoreLinks]: serverApi.QueryElemProperty.songStoreLinks, + [QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId, + [QueryLeafBy.TrackStoreLinks]: serverApi.QueryElemProperty.trackStoreLinks, [QueryLeafBy.ArtistStoreLinks]: serverApi.QueryElemProperty.artistStoreLinks, [QueryLeafBy.AlbumStoreLinks]: serverApi.QueryElemProperty.albumStoreLinks, } const leafOpsMapping: any = { - [QueryLeafOp.Equals]: serverApi.QueryFilterOp.Eq, - [QueryLeafOp.Like]: serverApi.QueryFilterOp.Like, + [QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq, + [QueryLeafOp.Like]: serverApi.QueryLeafOp.Like, } const nodeOpsMapping: any = { - [QueryNodeOp.And]: serverApi.QueryElemOp.And, - [QueryNodeOp.Or]: serverApi.QueryElemOp.Or, - [QueryNodeOp.Not]: serverApi.QueryElemOp.Not, + [QueryNodeOp.And]: serverApi.QueryNodeOp.And, + [QueryNodeOp.Or]: serverApi.QueryNodeOp.Or, + [QueryNodeOp.Not]: serverApi.QueryNodeOp.Not, } if(isLeafElem(q) && isTagQueryInfo(q.b)) { // Special case for tag queries by ID const r: serverApi.QueryElem = { prop: serverApi.QueryElemProperty.tagId, - propOperator: serverApi.QueryFilterOp.In, + propOperator: serverApi.QueryLeafOp.In, propOperand: q.b.matchIds, } return r; diff --git a/client/src/lib/saveChanges.tsx b/client/src/lib/saveChanges.tsx index 4a85c20..ef786bc 100644 --- a/client/src/lib/saveChanges.tsx +++ b/client/src/lib/saveChanges.tsx @@ -1,42 +1,42 @@ -import * as serverApi from '../api'; +import * as serverApi from '../api/api'; import backendRequest from './backend/request'; -export async function modifySong(id: number, change: serverApi.ModifySongRequest) { +export async function modifyTrack(id: number, change: serverApi.PatchTrackRequest) { const requestOpts = { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(change), }; - const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString()); + const endpoint = serverApi.PatchTrackEndpoint.replace(":id", id.toString()); const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { - throw new Error("Failed to save song changes: " + response.statusText); + throw new Error("Failed to save track changes: " + response.statusText); } } -export async function modifyArtist(id: number, change: serverApi.ModifyArtistRequest) { +export async function modifyArtist(id: number, change: serverApi.PatchArtistRequest) { const requestOpts = { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(change), }; - const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString()); + const endpoint = serverApi.PatchArtistEndpoint.replace(":id", id.toString()); const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { throw new Error("Failed to save artist changes: " + response.statusText); } } -export async function modifyAlbum(id: number, change: serverApi.ModifyAlbumRequest) { +export async function modifyAlbum(id: number, change: serverApi.PatchAlbumRequest) { const requestOpts = { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(change), }; - const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString()); + const endpoint = serverApi.PatchAlbumEndpoint.replace(":id", id.toString()); const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) if(!response.ok) { throw new Error("Failed to save album changes: " + response.statusText); diff --git a/client/src/lib/songGetters.tsx b/client/src/lib/songGetters.tsx deleted file mode 100644 index 4f75cc9..0000000 --- a/client/src/lib/songGetters.tsx +++ /dev/null @@ -1,28 +0,0 @@ -export const songGetters = { - getTitle: (song: any) => song.title, - getId: (song: any) => song.songId, - getArtistNames: (song: any) => song.artists.map((a: any) => a.name), - getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId), - getAlbumNames: (song: any) => song.albums.map((a: any) => a.name), - getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId), - getTagNames: (song: any) => { - // Recursively resolve the name. - const resolveTag = (tag: any) => { - var r = [tag.name]; - if (tag.parent) { r.unshift(resolveTag(tag.parent)); } - return r; - } - - return song.tags.map((tag: any) => resolveTag(tag)); - }, - getTagIds: (song: any) => { - // Recursively resolve the id. - const resolveTag = (tag: any) => { - var r = [tag.tagId]; - if (tag.parent) { r.unshift(resolveTag(tag.parent)); } - return r; - } - - return song.tags.map((tag: any) => resolveTag(tag)); - }, -} \ No newline at end of file diff --git a/client/src/lib/trackGetters.tsx b/client/src/lib/trackGetters.tsx new file mode 100644 index 0000000..6582f14 --- /dev/null +++ b/client/src/lib/trackGetters.tsx @@ -0,0 +1,28 @@ +export const trackGetters = { + getTitle: (track: any) => track.title, + 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), + getTagNames: (track: any) => { + // Recursively resolve the name. + const resolveTag = (tag: any) => { + var r = [tag.name]; + if (tag.parent) { r.unshift(resolveTag(tag.parent)); } + return r; + } + + return track.tags.map((tag: any) => resolveTag(tag)); + }, + getTagIds: (track: any) => { + // Recursively resolve the id. + const resolveTag = (tag: any) => { + var r = [tag.tagId]; + if (tag.parent) { r.unshift(resolveTag(tag.parent)); } + return r; + } + + return track.tags.map((tag: any) => resolveTag(tag)); + }, +} \ No newline at end of file diff --git a/client/src/lib/useAuth.tsx b/client/src/lib/useAuth.tsx index 51f208f..662e078 100644 --- a/client/src/lib/useAuth.tsx +++ b/client/src/lib/useAuth.tsx @@ -3,7 +3,7 @@ import React, { useState, useContext, createContext, ReactFragment } from "react"; import PersonIcon from '@material-ui/icons/Person'; -import * as serverApi from '../api'; +import * as serverApi from '../api/api'; export interface AuthUser { id: number, diff --git a/server/app.ts b/server/app.ts index acab868..0e4bba6 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,16 +1,14 @@ const bodyParser = require('body-parser'); -import * as api from '../client/src/api'; +import * as api from '../client/src/api/api'; import Knex from 'knex'; -import { Query } from './endpoints/Query'; - -import { PostArtist, PutArtist, GetArtist } from './endpoints/Artist'; -import { PostAlbum, PutAlbum, GetAlbum } from './endpoints/Album'; -import { PostSong, PutSong, GetSong } from './endpoints/Song'; -import { PostTag, PutTag, GetTag, DeleteTag, MergeTag } from './endpoints/Tag'; -import { PostIntegration, PutIntegration, GetIntegration, DeleteIntegration, ListIntegrations } from './endpoints/Integration'; - -import { RegisterUser } from './endpoints/RegisterUser'; +import { queryEndpoints } from './endpoints/Query'; +import { artistEndpoints } from './endpoints/Artist'; +import { albumEndpoints } from './endpoints/Album'; +import { trackEndpoints } from './endpoints/Track'; +import { tagEndpoints } from './endpoints/Tag'; +import { integrationEndpoints } from './endpoints/Integration'; +import { userEndpoints } from './endpoints/User'; import * as endpointTypes from './endpoints/types'; import { sha512 } from 'js-sha512'; @@ -24,10 +22,10 @@ const invokeHandler = (handler: endpointTypes.EndpointHandler, knex: Knex) => { return async (req: any, res: any) => { console.log("Incoming", req.method, " @ ", req.url); await handler(req, res, knex) - .catch(endpointTypes.catchUnhandledErrors) + .catch(endpointTypes.handleErrorsInEndpoint) .catch((_e: endpointTypes.EndpointError) => { let e: endpointTypes.EndpointError = _e; - console.log("Error handling request: ", e.internalMessage); + console.log("Error handling request: ", e.message); res.sendStatus(e.httpStatus); }) console.log("Finished handling", req.method, "@", req.url); @@ -104,34 +102,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { // Set up integration proxies app.use('/integrations', checkLogin(), createIntegrations(knex)); - // Set up REST API endpoints - app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong)); - app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(PutSong)); - app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(GetSong)); - - app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(Query)); - - app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(PostArtist)); - app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(PutArtist)); - app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(GetArtist)); - - app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(PostAlbum)); - app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(PutAlbum)); - app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(GetAlbum)); - - app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(PostTag)); - app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(PutTag)); - app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(GetTag)); - app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTag)); - app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTag)); - - app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(PostIntegration)); - app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(PutIntegration)); - app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(GetIntegration)); - app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegration)); - app.get(apiBaseUrl + api.ListIntegrationsEndpoint, checkLogin(), _invoke(ListIntegrations)); - - app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUser)); + // Set up auth endpoints app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => { res.status(200).send({ userId: req.user.id }); }); @@ -139,6 +110,26 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { req.logout(); res.status(200).send(); }); + + // Set up other endpoints + [ + albumEndpoints, + artistEndpoints, + tagEndpoints, + trackEndpoints, + integrationEndpoints, + userEndpoints, + queryEndpoints, + ].forEach((endpoints: [string, string, boolean, endpointTypes.EndpointHandler][]) => { + endpoints.forEach((endpoint: [string, string, boolean, endpointTypes.EndpointHandler]) => { + let [url, method, authenticated, handler] = endpoint; + if (authenticated) { + app[method](apiBaseUrl + url, checkLogin(), _invoke(handler)); + } else { + app[method](apiBaseUrl + url, _invoke(handler)); + } + }) + }); } export { SetupApp } \ No newline at end of file diff --git a/server/db/Album.ts b/server/db/Album.ts new file mode 100644 index 0000000..7c1cd69 --- /dev/null +++ b/server/db/Album.ts @@ -0,0 +1,405 @@ +import Knex from "knex"; +import { AlbumBaseWithRefs, AlbumWithDetails, AlbumWithRefs } from "../../client/src/api/api"; +import * as api from '../../client/src/api/api'; +import asJson from "../lib/asJson"; +import { DBError, DBErrorKind } from "../endpoints/types"; + +// Returns an album with details, or null if not found. +export async function getAlbum(id: number, userId: number, knex: Knex): + Promise { + // Start transfers for tracks, tags and artists. + // Also request the album itself. + const tagsPromise: Promise = + knex.select('tagId') + .from('albums_tags') + .where({ 'albumId': id }) + .then((tags: any) => tags.map((tag: any) => tag['tagId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'parentId']) + .from('tags') + .whereIn('id', ids) + ); + + const tracksPromise: Promise = + knex.select('trackId') + .from('tracks_albums') + .where({ 'albumId': id }) + .then((tracks: any) => tracks.map((track: any) => track['trackId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'storeLinks']) + .from('tracks') + .whereIn('id', ids) + ); + + const artistsPromise: Promise = + knex.select('artistId') + .from('artists_albums') + .where({ 'albumId': id }) + .then((artists: any) => artists.map((artist: any) => artist['artistId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'storeLinks']) + .from('artists') + .whereIn('id', ids) + ); + + const albumPromise: Promise = + knex.select('name', 'storeLinks') + .from('albums') + .where({ 'user': userId }) + .where({ id: id }) + .then((albums: any) => albums[0]); + + // Wait for the requests to finish. + const [album, tags, tracks, artists] = + await Promise.all([albumPromise, tagsPromise, tracksPromise, artistsPromise]); + + if (album) { + return { + mbApi_typename: 'album', + name: album['name'], + artists: artists as api.ArtistWithId[], + tags: tags as api.TagWithId[], + tracks: tracks as api.TrackWithId[], + storeLinks: asJson(album['storeLinks'] || []), + }; + } + + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; + throw e; +} + +// 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 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(); + throw e; + } + }) +} + +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') + .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(); + throw e; + } + }) + + const e: DBError = { + kind: DBErrorKind.Unknown, + message: "Reached the unreachable.", + name: "DBError" + } + throw e; +} + +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(); + throw e; + } + }) +} \ No newline at end of file diff --git a/server/db/Artist.ts b/server/db/Artist.ts new file mode 100644 index 0000000..94a1ac6 --- /dev/null +++ b/server/db/Artist.ts @@ -0,0 +1,323 @@ +import Knex from "knex"; +import { ArtistBaseWithRefs, ArtistWithDetails, ArtistWithRefs } from "../../client/src/api/api"; +import * as api from '../../client/src/api/api'; +import asJson from "../lib/asJson"; +import { DBError, DBErrorKind } from "../endpoints/types"; + +// Returns an artist with details, or null if not found. +export async function getArtist(id: number, userId: number, knex: Knex): + Promise { + // Start transfers for tags and albums. + // Also request the artist itself. + const tagsPromise: Promise = + knex.select('tagId') + .from('artists_tags') + .where({ 'artistId': id }) + .then((tags: any) => tags.map((tag: any) => tag['tagId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'parentId']) + .from('tags') + .whereIn('id', ids) + ); + + const albumsPromise: Promise = + knex.select('albumId') + .from('artists_albums') + .where({ 'artistId': id }) + .then((albums: any) => albums.map((album: any) => album['albumId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'storeLinks']) + .from('album') + .whereIn('id', ids) + ); + + const artistPromise: Promise = + knex.select('name', 'storeLinks') + .from('artists') + .where({ 'user': userId }) + .where({ id: id }) + .then((artists: any) => artists[0]); + + // Wait for the requests to finish. + const [artist, tags, albums] = + await Promise.all([artistPromise, tagsPromise, albumsPromise]); + + if (artist) { + return { + mbApi_typename: 'artist', + name: artist['name'], + albums: albums as api.AlbumWithId[], + tags: tags as api.TagWithId[], + storeLinks: asJson(artist['storeLinks'] || []), + }; + } + + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; + throw e; +} + +// 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 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(); + throw e; + } + }) +} + +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') + .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(); + throw e; + } + }) +} + +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(); + throw e; + } + }) +} \ No newline at end of file diff --git a/server/db/ImportExport.ts b/server/db/ImportExport.ts new file mode 100644 index 0000000..cb1f5b9 --- /dev/null +++ b/server/db/ImportExport.ts @@ -0,0 +1,204 @@ +import Knex from "knex"; +import { TrackWithRefsWithId, AlbumWithRefsWithId, ArtistWithRefsWithId, TagWithRefsWithId, TrackWithRefs } from "../../client/src/api/api"; +import * as api from '../../client/src/api/api'; +import asJson from "../lib/asJson"; +import { createArtist } from "./Artist"; +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 { + // First, retrieve all the objects without taking linking tables into account. + // Fetch the links separately. + + let tracksPromise: Promise = + knex.select('id', 'name', 'storeLinks', 'albumId') + .from('tracks') + .where({ 'user': userId }) + .then((ts: any[]) => ts.map((t: any) => { + return { + mbApi_typename: 'track', + name: t.name, + id: t.id, + storeLinks: asJson(t.storeLinks), + albumId: t.albumId, + artistIds: [], + tagIds: [], + } + })); + + let albumsPromise: Promise = + knex.select('name', 'storeLinks', 'id') + .from('albums') + .where({ 'user': userId }) + .then((ts: any[]) => ts.map((t: any) => { + return { + mbApi_typename: 'album', + id: t.id, + name: t.name, + storeLinks: asJson(t.storeLinks), + artistIds: [], + tagIds: [], + trackIds: [], + } + })); + + let artistsPromise: Promise = + knex.select('name', 'storeLinks', 'id') + .from('artists') + .where({ 'user': userId }) + .then((ts: any[]) => ts.map((t: any) => { + return { + mbApi_typename: 'artist', + id: t.id, + name: t.name, + storeLinks: asJson(t.storeLinks), + albumIds: [], + tagIds: [], + trackIds: [], + } + })); + + let tagsPromise: Promise = + knex.select('name', 'parentId', 'id') + .from('tags') + .where({ 'user': userId }) + .then((ts: any[]) => ts.map((t: any) => { + return { + mbApi_typename: 'tag', + id: t.id, + name: t.name, + parentId: t.parentId, + } + })); + + let tracksArtistsPromise: Promise<[number, number][]> = + knex.select('trackId', 'artistId') + .from('tracks_artists') + .then((rs: any) => rs.map((r: any) => [r.trackId, r.artistId])); + + let tracksTagsPromise: Promise<[number, number][]> = + knex.select('trackId', 'tagId') + .from('tracks_tags') + .then((rs: any) => rs.map((r: any) => [r.trackId, r.tagId])); + + let artistsTagsPromise: Promise<[number, number][]> = + knex.select('artistId', 'tagId') + .from('artists_tags') + .then((rs: any) => rs.map((r: any) => [r.artistId, r.tagId])); + + let albumsTagsPromise: Promise<[number, number][]> = + knex.select('albumId', 'tagId') + .from('albums_tags') + .then((rs: any) => rs.map((r: any) => [r.albumId, r.tagId])); + + let artistsAlbumsPromise: Promise<[number, number][]> = + knex.select('albumId', 'artistId') + .from('artists_albums') + .then((rs: any) => rs.map((r: any) => [r.albumId, r.artistId])); + + let [ + tracks, + albums, + artists, + tags, + tracksArtists, + tracksTags, + artistsTags, + albumsTags, + artistsAlbums, + ] = await Promise.all([ + tracksPromise, + albumsPromise, + artistsPromise, + tagsPromise, + tracksArtistsPromise, + tracksTagsPromise, + artistsTagsPromise, + albumsTagsPromise, + artistsAlbumsPromise, + ]); + + // Now store the links inside the resource objects. + tracksArtists.forEach((v: [number, number]) => { + let [trackId, artistId] = v; + tracks.find((t: TrackWithRefsWithId) => t.id === trackId)?.artistIds.push(artistId); + }) + tracksTags.forEach((v: [number, number]) => { + let [trackId, tagId] = v; + tracks.find((t: TrackWithRefsWithId) => t.id === trackId)?.tagIds.push(tagId); + }) + artistsTags.forEach((v: [number, number]) => { + let [artistId, tagId] = v; + artists.find((t: ArtistWithRefsWithId) => t.id === artistId)?.tagIds.push(tagId); + }) + albumsTags.forEach((v: [number, number]) => { + let [albumId, tagId] = v; + albums.find((t: AlbumWithRefsWithId) => t.id === albumId)?.tagIds.push(tagId); + }) + artistsAlbums.forEach((v: [number, number]) => { + let [artistId, albumId] = v; + artists.find((t: ArtistWithRefsWithId) => t.id === artistId)?.albumIds.push(albumId); + albums.find((t: AlbumWithRefsWithId) => t.id === albumId)?.artistIds.push(artistId); + }) + + return { + tracks: tracks, + albums: albums, + artists: artists, + tags: tags, + } +} + +export async function importDB(userId: number, db: 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(); + } + }); +} \ No newline at end of file diff --git a/server/db/Integration.ts b/server/db/Integration.ts new file mode 100644 index 0000000..0d13e9f --- /dev/null +++ b/server/db/Integration.ts @@ -0,0 +1,135 @@ +import * as api from '../../client/src/api/api'; +import Knex from 'knex'; +import asJson from '../lib/asJson'; +import { DBError, DBErrorKind } from '../endpoints/types'; +import { IntegrationDataWithId, IntegrationDataWithSecret, PartialIntegrationData } from '../../client/src/api/api'; + +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; + } + }) +} + +export async function getIntegration(userId: number, id: number, knex: Knex): Promise { + const integration = (await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ 'user': userId, 'id': id }))[0]; + + if (integration) { + const r: api.IntegrationData = { + mbApi_typename: "integrationData", + name: integration.name, + type: integration.type, + details: asJson(integration.details), + } + return r; + } else { + let e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: "Resource not found." + } + throw e; + } +} + +export async function listIntegrations(userId: number, knex: Knex): Promise { + const integrations: api.IntegrationDataWithId[] = ( + await knex.select(['id', 'name', 'type', 'details']) + .from('integrations') + .where({ user: userId }) + ).map((object: any) => { + return { + mbApi_typename: "integrationData", + id: object.id, + name: object.name, + type: object.type, + details: asJson(object.details), + } + }) + + return integrations; +} + +export async function deleteIntegration(userId: number, id: number, knex: Knex) { + await knex.transaction(async (trx) => { + 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(); + + } catch (e) { + trx.rollback(); + } + }) +} + +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; + } + + // 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(); + } + }) +} \ No newline at end of file diff --git a/server/db/Query.ts b/server/db/Query.ts new file mode 100644 index 0000000..69120c3 --- /dev/null +++ b/server/db/Query.ts @@ -0,0 +1,476 @@ +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from '../endpoints/types'; +import Knex from 'knex'; +import asJson from '../lib/asJson'; + +export function toApiTag(dbObj: any): api.Tag { + return { + mbApi_typename: "tag", + tagId: dbObj['tags.id'], + name: dbObj['tags.name'], + parentId: dbObj['tags.parentId'], + parent: dbObj.parent ? toApiTag(dbObj.parent) : undefined, + }; +} + +export function toApiArtist(dbObj: any): api.Artist { + return { + mbApi_typename: "artist", + artistId: dbObj['artists.id'], + name: dbObj['artists.name'], + storeLinks: asJson(dbObj['artists.storeLinks']), + }; +} + +export function toApiTrack(dbObj: any, artists: any[], tags: any[], album: any | undefined): api.Track { + return { + mbApi_typename: "track", + trackId: dbObj['tracks.id'], + name: dbObj['tracks.name'], + storeLinks: asJson(dbObj['tracks.storeLinks']), + artists: artists.map((artist: any) => { + return toApiArtist(artist); + }), + tags: tags.map((tag: any) => { + return toApiTag(tag); + }), + album: album, + } +} + +export function toApiAlbum(dbObj: any): api.Album { + return { + mbApi_typename: "album", + albumId: dbObj['albums.id'], + name: dbObj['albums.name'], + storeLinks: asJson(dbObj['albums.storeLinks']), + }; +} + +enum ObjectType { + Track = 0, + Artist, + Tag, + Album, +} + +// To keep track of which database objects are needed to filter on +// certain properties. +const propertyObjects: Record = { + [api.QueryElemProperty.albumId]: ObjectType.Album, + [api.QueryElemProperty.albumName]: ObjectType.Album, + [api.QueryElemProperty.artistId]: ObjectType.Artist, + [api.QueryElemProperty.artistName]: ObjectType.Artist, + [api.QueryElemProperty.trackId]: ObjectType.Track, + [api.QueryElemProperty.trackName]: ObjectType.Track, + [api.QueryElemProperty.tagId]: ObjectType.Tag, + [api.QueryElemProperty.tagName]: ObjectType.Tag, + [api.QueryElemProperty.trackStoreLinks]: ObjectType.Track, + [api.QueryElemProperty.artistStoreLinks]: ObjectType.Artist, + [api.QueryElemProperty.albumStoreLinks]: ObjectType.Album, +} + +// To keep track of the tables in which objects are stored. +const objectTables: Record = { + [ObjectType.Album]: 'albums', + [ObjectType.Artist]: 'artists', + [ObjectType.Track]: 'tracks', + [ObjectType.Tag]: 'tags', +} + +// 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 { + 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; + + throw "Could not find linking table for objects: " + JSON.stringify(a) + ", " + JSON.stringify(b); +} + +// To keep track of ID fields used in linking tables. +const linkingTableIdNames: Record = { + [ObjectType.Album]: 'albumId', + [ObjectType.Artist]: 'artistId', + [ObjectType.Track]: 'trackId', + [ObjectType.Tag]: 'tagId', +} + +function getRequiredDatabaseObjects(queryElem: api.QueryElem): Set { + if (queryElem.prop) { + // Leaf node. + return new Set([propertyObjects[queryElem.prop]]); + } else if (queryElem.children) { + // Branch node. + var r = new Set(); + queryElem.children.forEach((child: api.QueryElem) => { + getRequiredDatabaseObjects(child).forEach(object => r.add(object)); + }); + return r; + } + return new Set([]); +} + +function addJoin(knexQuery: any, base: ObjectType, other: ObjectType) { + const linkTable = getLinkingTable(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] }); +} + +enum WhereType { + And = 0, + Or, +}; + +function getSQLValue(val: any) { + console.log("Value:", val) + if (typeof val === 'string') { + return `'${val}'`; + } else if (typeof val === 'number') { + return `${val}`; + } + throw new Error("unimplemented SQL value type."); +} + +function getSQLValues(vals: any[]) { + if (vals.length === 0) { return '()' } + let r = `(${getSQLValue(vals[0])}`; + for (let i: number = 1; i < vals.length; i++) { + r += `, ${getSQLValue(vals[i])}`; + } + r += ')'; + return r; +} + +function getLeafWhere(queryElem: api.QueryElem): string { + const simpleLeafOps: Record = { + [api.QueryLeafOp.Eq]: "=", + [api.QueryLeafOp.Ne]: "!=", + [api.QueryLeafOp.Like]: "LIKE", + } + + const propertyKeys = { + [api.QueryElemProperty.trackName]: '`tracks`.`name`', + [api.QueryElemProperty.trackId]: '`tracks`.`id`', + [api.QueryElemProperty.artistName]: '`artists`.`name`', + [api.QueryElemProperty.artistId]: '`artists`.`id`', + [api.QueryElemProperty.albumName]: '`albums`.`name`', + [api.QueryElemProperty.albumId]: '`albums`.`id`', + [api.QueryElemProperty.tagId]: '`tags`.`id`', + [api.QueryElemProperty.tagName]: '`tags`.`name`', + [api.QueryElemProperty.trackStoreLinks]: '`tracks`.`storeLinks`', + [api.QueryElemProperty.artistStoreLinks]: '`artists`.`storeLinks`', + [api.QueryElemProperty.albumStoreLinks]: '`albums`.`storeLinks`', + } + + if (!queryElem.propOperator) throw "Cannot create where clause without an operator."; + const operator = queryElem.propOperator || api.QueryLeafOp.Eq; + const a = queryElem.prop && propertyKeys[queryElem.prop]; + const b = operator === api.QueryLeafOp.Like ? + '%' + (queryElem.propOperand || "") + '%' + : (queryElem.propOperand || ""); + + if (Object.keys(simpleLeafOps).includes(operator)) { + return `(${a} ${simpleLeafOps[operator]} ${getSQLValue(b)})`; + } else if (operator == api.QueryLeafOp.In) { + return `(${a} IN ${getSQLValues(b)})` + } else if (operator == api.QueryLeafOp.NotIn) { + return `(${a} NOT IN ${getSQLValues(b)})` + } + + throw "Query filter not implemented"; +} + +function getNodeWhere(queryElem: api.QueryElem): string { + let ops = { + [api.QueryNodeOp.And]: 'AND', + [api.QueryNodeOp.Or]: 'OR', + [api.QueryNodeOp.Not]: 'NOT', + } + let buildList = (subqueries: api.QueryElem[], operator: api.QueryNodeOp) => { + if (subqueries.length === 0) { return 'true' } + let r = `(${getWhere(subqueries[0])}`; + for (let i: number = 1; i < subqueries.length; i++) { + r += ` ${ops[operator]} ${getWhere(subqueries[i])}`; + } + r += ')'; + return r; + } + + if (queryElem.children && queryElem.childrenOperator && queryElem.children.length) { + if (queryElem.childrenOperator === api.QueryNodeOp.And || + queryElem.childrenOperator === api.QueryNodeOp.Or) { + return buildList(queryElem.children, queryElem.childrenOperator) + } else if (queryElem.childrenOperator === api.QueryNodeOp.Not && + queryElem.children.length === 1) { + return `NOT ${getWhere(queryElem.children[0])}` + } + } + + throw new Error('invalid query') +} + +function getWhere(queryElem: api.QueryElem): string { + if (queryElem.prop) { return getLeafWhere(queryElem); } + if (queryElem.children) { return getNodeWhere(queryElem); } + return "true"; +} + +const objectColumns = { + [ObjectType.Track]: ['tracks.id as tracks.id', 'tracks.title as tracks.title', 'tracks.storeLinks as tracks.storeLinks'], + [ObjectType.Artist]: ['artists.id as artists.id', 'artists.name as artists.name', 'artists.storeLinks as artists.storeLinks'], + [ObjectType.Album]: ['albums.id as albums.id', 'albums.name as albums.name', 'albums.storeLinks as albums.storeLinks'], + [ObjectType.Tag]: ['tags.id as tags.id', 'tags.name as tags.name', 'tags.parentId as tags.parentId'] +}; + +function constructQuery(knex: Knex, userId: number, queryFor: ObjectType, queryElem: api.QueryElem, ordering: api.Ordering, + offset: number, limit: number | null) { + const joinObjects = getRequiredDatabaseObjects(queryElem); + joinObjects.delete(queryFor); // We are already querying this object in the base query. + + // Figure out what data we want to select from the results. + var columns: any[] = objectColumns[queryFor]; + + // TODO: there was a line here to add columns for the joined objects. + // Could not get it to work with Postgres, which wants aggregate functions + // to specify exactly how duplicates should be aggregated. + // Not sure whether we need these columns in the first place. + // joinObjects.forEach((obj: ObjectType) => columns.push(...objectColumns[obj])); + + // First, we create a base query for the type of object we need to yield. + var q = knex.select(columns) + .where({ [objectTables[queryFor] + '.user']: userId }) + .groupBy(objectTables[queryFor] + '.' + 'id') + .from(objectTables[queryFor]); + + // Now, we need to add join statements for other objects we want to filter on. + joinObjects.forEach((object: ObjectType) => { + q = addJoin(q, queryFor, object); + }) + + // Apply filtering. + q = q.andWhereRaw(getWhere(queryElem)); + + // Apply ordering + const orderKeys = { + [api.OrderByType.Name]: objectTables[queryFor] + '.' + ((queryFor === ObjectType.Track) ? 'title' : 'name') + }; + q = q.orderBy(orderKeys[ordering.orderBy.type], + (ordering.ascending ? 'asc' : 'desc')); + + // Apply limiting. + if (limit !== null) { + q = q.limit(limit) + } + + // Apply offsetting. + q = q.offset(offset); + + return q; +} + +async function getLinkedObjects(knex: Knex, userId: number, base: ObjectType, linked: ObjectType, baseIds: number[]) { + var result: Record = {}; + const otherTable = objectTables[linked]; + const linkingTable = getLinkingTable(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("Query results for", baseIds, ":", result); + return result; +} + +// Resolve a tag into the full nested structure of its ancestors. +async function getFullTag(knex: Knex, userId: number, tag: any): Promise { + const resolveTag = async (t: any) => { + if (t['tags.parentId']) { + const parent = (await knex.select(objectColumns[ObjectType.Tag]) + .from('tags') + .where({ 'user': userId }) + .where({ [objectTables[ObjectType.Tag] + '.id']: t['tags.parentId'] }))[0]; + t.parent = await resolveTag(parent); + } + return t; + } + + return await resolveTag(tag); +} + +export async function doQuery(userId: number, q: api.QueryRequest, knex: Knex): Promise { + const trackLimit = q.offsetsLimits.trackLimit; + const trackOffset = q.offsetsLimits.trackOffset; + const tagLimit = q.offsetsLimits.tagLimit; + const tagOffset = q.offsetsLimits.tagOffset; + const artistLimit = q.offsetsLimits.artistLimit; + const artistOffset = q.offsetsLimits.artistOffset; + const albumLimit = q.offsetsLimits.albumLimit; + const albumOffset = q.offsetsLimits.albumOffset; + + const artistsPromise: Promise = (artistLimit && artistLimit !== 0) ? + constructQuery(knex, + userId, + ObjectType.Artist, + q.query, + q.ordering, + artistOffset || 0, + artistLimit >= 0 ? artistLimit : null, + ) : + (async () => [])(); + + const albumsPromise: Promise = (albumLimit && albumLimit !== 0) ? + constructQuery(knex, + userId, + ObjectType.Album, + q.query, + q.ordering, + artistOffset || 0, + albumLimit >= 0 ? albumLimit : null, + ) : + (async () => [])(); + + const tracksPromise: Promise = (trackLimit && trackLimit !== 0) ? + constructQuery(knex, + userId, + ObjectType.Track, + q.query, + q.ordering, + trackOffset || 0, + trackLimit >= 0 ? trackLimit : null, + ) : + (async () => [])(); + + const tagsPromise: Promise = (tagLimit && tagLimit !== 0) ? + constructQuery(knex, + userId, + ObjectType.Tag, + q.query, + q.ordering, + tagOffset || 0, + tagLimit >= 0 ? tagLimit : null, + ) : + (async () => [])(); + + // For some objects, we want to return linked information as well. + // For that we need to do further queries. + const trackIdsPromise = (async () => { + const tracks = await tracksPromise; + const ids = tracks.map((track: any) => track['tracks.id']); + return ids; + })(); + const tracksArtistsPromise: Promise> = (trackLimit && trackLimit !== 0) ? + (async () => { + return await getLinkedObjects(knex, userId, ObjectType.Track, ObjectType.Artist, await trackIdsPromise); + })() : + (async () => { return {}; })(); + const tracksTagsPromise: Promise> = (trackLimit && trackLimit !== 0) ? + (async () => { + const tagsPerTrack: Record = await getLinkedObjects(knex, userId, ObjectType.Track, ObjectType.Tag, await trackIdsPromise); + var result: Record = {}; + for (var key in tagsPerTrack) { + const tags = tagsPerTrack[key]; + var fullTags: any[] = []; + for (var idx in tags) { + fullTags.push(await getFullTag(knex, userId, tags[idx])); + } + result[key] = fullTags; + } + return result; + })() : + (async () => { return {}; })(); + const tracksAlbumsPromise: Promise> = (trackLimit && trackLimit !== 0) ? + (async () => { + return await getLinkedObjects(knex, userId, ObjectType.Track, ObjectType.Album, await trackIdsPromise); + })() : + (async () => { return {}; })(); + + const [ + tracks, + artists, + albums, + tags, + tracksArtists, + tracksTags, + tracksAlbums, + ] = + await Promise.all([ + tracksPromise, + artistsPromise, + albumsPromise, + tagsPromise, + tracksArtistsPromise, + tracksTagsPromise, + tracksAlbumsPromise, + ]); + + var response: api.QueryResponse = { + tracks: [], + artists: [], + albums: [], + tags: [], + }; + + switch (q.responseType) { + case api.QueryResponseType.Details: { + response = { + tracks: tracks.map((track: any) => { + const id = track['tracks.id']; + return toApiTrack(track, tracksArtists[id], tracksTags[id], tracksAlbums[id]); + }), + artists: artists.map((artist: any) => { + return toApiArtist(artist); + }), + albums: albums.map((album: any) => { + return toApiAlbum(album); + }), + tags: tags.map((tag: any) => { + return toApiTag(tag); + }), + }; + break; + } + case api.QueryResponseType.Ids: { + response = { + tracks: tracks.map((track: any) => track['tracks.id']), + artists: artists.map((artist: any) => artist['artists.id']), + albums: albums.map((album: any) => album['albums.id']), + tags: tags.map((tag: any) => tag['tags.id']), + }; + break; + } + case api.QueryResponseType.Count: { + response = { + tracks: tracks.length, + artists: artists.length, + albums: albums.length, + tags: tags.length, + }; + break; + } + default: { + throw new Error("Unimplemented response type.") + } + } + + return response; +} \ No newline at end of file diff --git a/server/db/Tag.ts b/server/db/Tag.ts new file mode 100644 index 0000000..8be6127 --- /dev/null +++ b/server/db/Tag.ts @@ -0,0 +1,274 @@ +import Knex from "knex"; +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"; + +export async function getTagChildrenRecursive(id: number, userId: number, trx: any): Promise { + const directChildren = (await trx.select('id') + .from('tags') + .where({ 'user': userId }) + .where({ 'parentId': id })).map((r: any) => r.id); + + const indirectChildrenPromises = directChildren.map( + (child: number) => getTagChildrenRecursive(child, userId, trx) + ); + const indirectChildrenNested = await Promise.all(indirectChildrenPromises); + const indirectChildren = indirectChildrenNested.flat(); + + return [ + ...directChildren, + ...indirectChildren, + ] +} + +// 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 (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; + } + }) +} + +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 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(); + + await Promise.all([deleteArtistsPromise, deleteAlbumsPromise, deleteTracksPromise, deleteTags]) + } catch (e) { + trx.rollback(); + throw e; + } + }) +} + +export async function getTag(userId: number, tagId: number, knex: Knex): Promise { + const tagPromise: Promise = + knex.select(['id', 'name', 'parentId']) + .from('tags') + .where({ 'user': userId }) + .where({ 'id': tagId }) + .then((r: TagWithRefsWithId[] | undefined) => r ? r[0] : undefined); + + const parentPromise: Promise = + tagPromise + .then((r: TagWithRefsWithId | undefined) => + (r && r.parentId) ? ( + getTag(userId, r.parentId, knex) + .then((rr: TagWithDetails | null) => rr ? { ...rr, id: r.parentId || 0 } : null) + ) : null + ) + + const [maybeTag, maybeParent] = await Promise.all([tagPromise, parentPromise]); + + if (maybeTag) { + let result: TagWithDetails = { + mbApi_typename: "tag", + name: maybeTag.name, + parent: maybeParent, + } + return result; + } else { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; + throw e; + } +} + +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') + .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(); + throw e; + } + }) +} + +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 "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(); + throw e; + } + }) +} \ No newline at end of file diff --git a/server/db/Track.ts b/server/db/Track.ts new file mode 100644 index 0000000..e693424 --- /dev/null +++ b/server/db/Track.ts @@ -0,0 +1,343 @@ +import Knex from "knex"; +import { TrackBaseWithRefs, TrackWithDetails, TrackWithRefs } from "../../client/src/api/api"; +import * as api from '../../client/src/api/api'; +import asJson from "../lib/asJson"; +import { DBError, DBErrorKind } from "../endpoints/types"; + +// Returns an track with details, or null if not found. +export async function getTrack(id: number, userId: number, knex: Knex): + Promise { + // Start transfers for tracks, tags and artists. + // Also request the track itself. + const tagsPromise: Promise = + knex.select('tagId') + .from('tracks_tags') + .where({ 'trackId': id }) + .then((tags: any) => tags.map((tag: any) => tag['tagId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'parentId']) + .from('tags') + .whereIn('id', ids) + ); + + const artistsPromise: Promise = + knex.select('artistId') + .from('artists_tracks') + .where({ 'trackId': id }) + .then((artists: any) => artists.map((artist: any) => artist['artistId'])) + .then((ids: number[]) => + knex.select(['id', 'name', 'storeLinks']) + .from('artists') + .whereIn('id', ids) + ); + + const trackPromise: Promise = + knex.select('name', 'storeLinks') + .from('tracks') + .where({ 'user': userId }) + .where({ id: id }) + .then((tracks: any) => tracks[0]); + + + const albumPromise: Promise = + trackPromise + .then((t: api.Track | undefined) => + t ? knex.select('id', 'name', 'storeLinks') + .from('albums') + .where({ 'user': userId }) + .where({ id: t.albumId }) + .then((albums: any) => albums.length > 0 ? albums[0] : null) + : (() => null)() + ) + + // Wait for the requests to finish. + const [track, tags, album, artists] = + await Promise.all([trackPromise, tagsPromise, albumPromise, artistsPromise]); + + if (track) { + return { + mbApi_typename: 'track', + name: track['name'], + artists: artists as api.ArtistWithId[], + tags: tags as api.TagWithId[], + album: album as api.AlbumWithId | null, + storeLinks: asJson(track['storeLinks'] || []), + }; + } else { + const e: DBError = { + name: "DBError", + kind: DBErrorKind.ResourceNotFound, + message: 'Not all resources were found.', + }; + throw e; + } +} + +// 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 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') + .from('albums') + .where({ 'user': userId, 'albumId': track.albumId }) + .then((albums: any) => albums.map((album: any) => album['albumId'])) + .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(); + throw e; + } + }) +} + +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') + .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(); + throw e; + } + }) +} + +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(); + throw e; + } + }) +} \ No newline at end of file diff --git a/server/db/User.ts b/server/db/User.ts new file mode 100644 index 0000000..258489b --- /dev/null +++ b/server/db/User.ts @@ -0,0 +1,39 @@ +import * as api from '../../client/src/api/api'; +import Knex from 'knex'; + +import { sha512 } from 'js-sha512'; +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; + } + + // 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(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/Album.ts b/server/endpoints/Album.ts index 3af16ea..cdf1770 100644 --- a/server/endpoints/Album.ts +++ b/server/endpoints/Album.ts @@ -1,314 +1,113 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; import Knex from 'knex'; import asJson from '../lib/asJson'; +import { AlbumWithDetails } from '../../client/src/api/api'; +import { createAlbum, deleteAlbum, getAlbum, modifyAlbum } from '../db/Album'; +import { GetArtist } from './Artist'; 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; - } - const { id: userId } = req.user; 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 maybeAlbum: api.GetAlbumResponse | null = + await getAlbum(req.params.id, userId, knex); - 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); + if (maybeAlbum) { + await res.send(maybeAlbum); } else { await res.status(404).send({}); } + } catch (e) { - catchUnhandledErrors(e); + handleErrorsInEndpoint(e); } } export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateAlbumRequest(req)) { + if (!api.checkPostAlbumRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PostAlbum request', httpStatus: 400 }; throw e; } - const reqObject: api.CreateAlbumRequest = req.body; + const reqObject: api.PostAlbumRequest = 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 - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) + try { + let id = await createAlbum(userId, reqObject, knex); + res.status(200).send(id); + } catch (e) { + handleErrorsInEndpoint(e); + } } export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyAlbumRequest(req)) { + if (!api.checkPutAlbumRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PutAlbum request', httpStatus: 400 }; throw e; } - const reqObject: api.ModifyAlbumRequest = req.body; + const reqObject: api.PutAlbumRequest = 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; + try { + modifyAlbum(userId, req.params.id, reqObject, knex); + res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} - // Remove unlinked tags. - // TODO: test this! - const removeUnlinkedTags = tags ? trx('albums_tags') - .where({ 'albumId': req.params.id }) - .whereNotIn('tagId', reqObject.tagIds || []) - .delete() : undefined; +export const PatchAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPatchAlbumRequest(req)) { + const e: EndpointError = { + name: "EndpointError", + message: 'Invalid PatchAlbum request', + httpStatus: 400 + }; + throw e; + } + const reqObject: api.PatchAlbumRequest = req.body; + const { id: userId } = req.user; - // 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, - } - }) + console.log("User ", userId, ": Patch Album ", reqObject); - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('artists_albums').insert(obj) - ) - ); - }) : undefined; + try { + modifyAlbum(userId, req.params.id, reqObject, knex); + res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} - // 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, - } - }) +export const DeleteAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { + const { id: userId } = req.user; - // Link them - return Promise.all( - insertObjects.map((obj: any) => - trx('albums_tags').insert(obj) - ) - ); - }) : undefined; + console.log("User ", userId, ": Delete Album ", req.params.id); - // Wait for all operations to finish. - await Promise.all([ - modifyAlbumPromise, - removeUnlinkedArtists, - removeUnlinkedTags, - addArtists, - addTags - ]); + try { + await deleteAlbum(userId, req.params.id, knex); + res.status(200).send(); - // Respond to the request. - res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file +export const albumEndpoints: [ string, string, boolean, EndpointHandler ][] = [ + [ api.PostAlbumEndpoint, 'post', true, PostAlbum ], + [ api.GetAlbumEndpoint, 'get', true, GetAlbum ], + [ api.PutAlbumEndpoint, 'put', true, PutAlbum ], + [ api.PatchAlbumEndpoint, 'patch', true, PatchAlbum ], + [ api.DeleteAlbumEndpoint, 'delete', true, DeleteAlbum ], +]; \ No newline at end of file diff --git a/server/endpoints/Artist.ts b/server/endpoints/Artist.ts index f18c9c6..1cdf455 100644 --- a/server/endpoints/Artist.ts +++ b/server/endpoints/Artist.ts @@ -1,221 +1,108 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; import Knex from 'knex'; import asJson from '../lib/asJson'; +import { createArtist, deleteArtist, getArtist, modifyArtist } from '../db/Artist'; export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkArtistDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid GetArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const { id: userId } = req.user; try { - const tags: api.TagDetailsResponseWithId[] = await knex.select('tagId') - .from('artists_tags') - .where({ 'artistId': 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 results = await knex.select(['id', 'name', 'storeLinks']) - .from('artists') - .where({ 'user': userId }) - .where({ 'id': req.params.id }); - - if (results[0]) { - const response: api.ArtistDetailsResponse = { - name: results[0].name, - tags: tags, - storeLinks: asJson(results[0].storeLinks), - } - await res.send(response); - } else { - await res.status(404).send({}); - } + let artist = await getArtist(req.params.id, userId, knex); + await res.status(200).send(artist); } catch (e) { - catchUnhandledErrors(e) + handleErrorsInEndpoint(e) } } export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateArtistRequest(req)) { + if (!api.checkPostArtistRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PostArtist request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PostArtist request', httpStatus: 400 }; throw e; } - const reqObject: api.CreateArtistRequest = req.body; + const reqObject: api.PostArtistRequest = req.body; const { id: userId } = req.user; console.log("User ", userId, ": Create artist ", reqObject) - await knex.transaction(async (trx) => { - try { - // Retrieve tag instances to link the artist to. - const tags: number[] = reqObject.tagIds ? - Array.from(new Set( - (await trx.select('id').from('tags') - .where({ 'user': userId }) - .whereIn('id', reqObject.tagIds)) - .map((tag: any) => tag['id']) - )) - : []; - - if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) { - const e: EndpointError = { - internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the artist. - const artistId = (await trx('artists') - .insert({ - name: reqObject.name, - storeLinks: JSON.stringify(reqObject.storeLinks || []), - user: userId, - }) - .returning('id') // Needed for Postgres - )[0]; - - // 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, - } - }) - ) - } - - const responseObject: api.CreateSongResponse = { - id: artistId - }; - await res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }); + try { + const id = await createArtist(userId, reqObject, knex); + await res.status(200).send({ id: id }); + + } catch (e) { + handleErrorsInEndpoint(e); + } } export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyArtistRequest(req)) { + if (!api.checkPutArtistRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PutArtist request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PutArtist request', httpStatus: 400 }; throw e; } - const reqObject: api.ModifyArtistRequest = req.body; + const reqObject: api.PutArtistRequest = req.body; const { id: userId } = req.user; console.log("User ", userId, ": Put Artist ", reqObject); - await knex.transaction(async (trx) => { - try { - const artistId = parseInt(req.params.id); - - // Start retrieving the artist itself. - const artistPromise = trx.select('id') - .from('artists') - .where({ 'user': userId }) - .where({ id: artistId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Start retrieving tags. - const tagIdsPromise = reqObject.tagIds ? - trx.select('id') - .from('artists_tags') - .whereIn('id', reqObject.tagIds) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return undefined })(); - - // Wait for the requests to finish. - var [artist, tags] = await Promise.all([artistPromise, tagIdsPromise]);; - - // Check that we found all objects we need. - if ((reqObject.tagIds && tags.length !== reqObject.tagIds.length) || - !artist) { - const e: EndpointError = { - internalMessage: 'Not all artists and/or tags exist for ModifyArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the artist. - var update: any = {}; - if ("name" in reqObject) { update["name"] = reqObject.name; } - if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); } - const modifyArtistPromise = trx('artists') - .where({ 'user': userId }) - .where({ 'id': artistId }) - .update(update) - - // Remove unlinked tags. - // TODO: test this! - const removeUnlinkedTags = tags ? - trx('artists_tags') - .where({ 'artistId': artistId }) - .whereNotIn('tagId', reqObject.tagIds || []) - .delete() : - undefined; - - // Link new tags. - // TODO: test this! - 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, - removeUnlinkedTags, - addTags - ]); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file + try { + await modifyArtist(userId, req.params.id, reqObject, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const PatchArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPatchArtistRequest(req)) { + const e: EndpointError = { + name: "EndpointError", + message: 'Invalid PatchArtist request', + httpStatus: 400 + }; + throw e; + } + const reqObject: api.PatchArtistRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Patch Artist ", reqObject); + + try { + await modifyArtist(userId, req.params.id, reqObject, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const DeleteArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Artist ", req.params.id); + + try { + await deleteArtist(userId, req.params.id, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const artistEndpoints: [ string, string, boolean, EndpointHandler ][] = [ + [ api.PostArtistEndpoint, 'post', true, PostArtist ], + [ api.GetArtistEndpoint, 'get', true, GetArtist ], + [ api.PutArtistEndpoint, 'put', true, PutArtist ], + [ api.PatchArtistEndpoint, 'patch', true, PatchArtist ], + [ api.DeleteArtistEndpoint, 'delete', true, DeleteArtist ], + ]; \ No newline at end of file diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts index 6e32c46..b872362 100644 --- a/server/endpoints/Integration.ts +++ b/server/endpoints/Integration.ts @@ -1,206 +1,122 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; import Knex from 'knex'; import asJson from '../lib/asJson'; +import { createIntegration, deleteIntegration, getIntegration, listIntegrations, modifyIntegration } from '../db/Integration'; +import { IntegrationDataWithId } from '../../client/src/api/api'; export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateIntegrationRequest(req)) { + if (!api.checkPostIntegrationRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PostIntegration request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PostIntegration request', httpStatus: 400 }; throw e; } - const reqObject: api.CreateIntegrationRequest = req.body; + const reqObject: api.PostIntegrationRequest = req.body; const { id: userId } = req.user; console.log("User ", userId, ": Post Integration ", reqObject); - await knex.transaction(async (trx) => { - try { - // Create the new integration. - var integration: any = { - name: reqObject.name, - user: userId, - type: reqObject.type, - details: JSON.stringify(reqObject.details), - secretDetails: JSON.stringify(reqObject.secretDetails), - } - const integrationId = (await trx('integrations') - .insert(integration) - .returning('id') // Needed for Postgres - )[0]; - - // Respond to the request. - const responseObject: api.CreateIntegrationResponse = { - id: integrationId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} - -export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkIntegrationDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid GetIntegration request: ' + JSON.stringify(req.body), - httpStatus: 400 + try { + let id = await createIntegration(userId, reqObject, knex); + const responseObject: api.PostIntegrationResponse = { + id: id }; - throw e; - } + res.status(200).send(responseObject); - const { id: userId } = req.user; + } catch (e) { + handleErrorsInEndpoint(e); + } +} +export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { try { - const integration = (await knex.select(['id', 'name', 'type', 'details']) - .from('integrations') - .where({ 'user': userId, 'id': req.params.id }))[0]; - - if (integration) { - const response: api.IntegrationDetailsResponse = { - name: integration.name, - type: integration.type, - details: asJson(integration.details), - } - await res.send(response); - } else { - await res.status(404).send({}); - } + let integration = await getIntegration(req.user.id, req.params.id, knex); + res.status(200).send(integration); } catch (e) { - catchUnhandledErrors(e) + handleErrorsInEndpoint(e) } } export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkIntegrationDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid ListIntegrations request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; + const { id: userId } = req.user; + console.log("List integrations"); + try { + const integrations: IntegrationDataWithId[] = await listIntegrations(req.user.id, knex); + console.log("Found integrations:", integrations); + await res.status(200).send(integrations); + } catch (e) { + handleErrorsInEndpoint(e) } +} +export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { const { id: userId } = req.user; - console.log("List integrations"); + console.log("User ", userId, ": Delete Integration ", req.params.id); try { - const integrations: api.ListIntegrationsResponse = ( - await knex.select(['id', 'name', 'type', 'details']) - .from('integrations') - .where({ user: userId }) - ).map((object: any) => { - return { - id: object.id, - name: object.name, - type: object.type, - details: asJson(object.details), - } - }) + await deleteIntegration(userId, req.params.id, knex); + res.status(200).send(); - console.log("Found integrations:", integrations); - await res.send(integrations); } catch (e) { - catchUnhandledErrors(e) + handleErrorsInEndpoint(e); } } -export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkDeleteIntegrationRequest(req)) { +export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPutIntegrationRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PutIntegration request', httpStatus: 400 }; throw e; } - const reqObject: api.DeleteIntegrationRequest = req.body; + const reqObject: api.PutIntegrationRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Delete Integration ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving the integration itself. - const integrationId = await trx.select('id') - .from('integrations') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Check that we found all objects we need. - if (!integrationId) { - const e: EndpointError = { - internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body), - httpStatus: 404 - }; - throw e; - } - - // Delete the integration. - await trx('integrations') - .where({ 'user': userId, 'id': integrationId }) - .del(); - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) + console.log("User ", userId, ": Put Integration ", reqObject); + + try { + await modifyIntegration(userId, req.params.id, reqObject, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } } -export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyIntegrationRequest(req)) { +export const PatchIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPatchIntegrationRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PutIntegration request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PatchIntegration request', httpStatus: 400 }; throw e; } - const reqObject: api.ModifyIntegrationRequest = req.body; + const reqObject: api.PatchIntegrationRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Put Integration ", reqObject); + console.log("User ", userId, ": Patch Integration ", reqObject); + + try { + await modifyIntegration(userId, req.params.id, reqObject, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} - 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: EndpointError = { - internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), - httpStatus: 404 - }; - throw e; - } - - // Modify the integration. - var update: any = {}; - if ("name" in reqObject) { update["name"] = reqObject.name; } - if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } - if ("type" in reqObject) { update["type"] = reqObject.type; } - if ("secretDetails" in reqObject) { update["secretDetails"] = JSON.stringify(reqObject.details); } - await trx('integrations') - .where({ 'user': userId, 'id': req.params.id }) - .update(update) - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file +export const integrationEndpoints: [string, string, boolean, EndpointHandler][] = [ + [api.PostIntegrationEndpoint, 'post', true, PostIntegration], + [api.GetIntegrationEndpoint, 'get', true, GetIntegration], + [api.PutIntegrationEndpoint, 'put', true, PutIntegration], + [api.PatchIntegrationEndpoint, 'patch', true, PatchIntegration], + [api.DeleteIntegrationEndpoint, 'delete', true, DeleteIntegration], + [api.ListIntegrationsEndpoint, 'get', true, ListIntegrations], +]; \ No newline at end of file diff --git a/server/endpoints/Query.ts b/server/endpoints/Query.ts index ea5321a..b0f3294 100644 --- a/server/endpoints/Query.ts +++ b/server/endpoints/Query.ts @@ -1,281 +1,13 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; import Knex from 'knex'; -import asJson from '../lib/asJson'; -import { toApiArtist, toApiTag, toApiAlbum, toApiSong } from '../lib/dbToApi'; - -enum ObjectType { - Song = 0, - Artist, - Tag, - Album, -} - -// To keep track of which database objects are needed to filter on -// certain properties. -const propertyObjects: Record = { - [api.QueryElemProperty.albumId]: ObjectType.Album, - [api.QueryElemProperty.albumName]: ObjectType.Album, - [api.QueryElemProperty.artistId]: ObjectType.Artist, - [api.QueryElemProperty.artistName]: ObjectType.Artist, - [api.QueryElemProperty.songId]: ObjectType.Song, - [api.QueryElemProperty.songTitle]: ObjectType.Song, - [api.QueryElemProperty.tagId]: ObjectType.Tag, - [api.QueryElemProperty.songStoreLinks]: ObjectType.Song, - [api.QueryElemProperty.artistStoreLinks]: ObjectType.Artist, - [api.QueryElemProperty.albumStoreLinks]: ObjectType.Album, -} - -// To keep track of the tables in which objects are stored. -const objectTables: Record = { - [ObjectType.Album]: 'albums', - [ObjectType.Artist]: 'artists', - [ObjectType.Song]: 'songs', - [ObjectType.Tag]: 'tags', -} - -// To keep track of linking tables between objects. -const linkingTables: any = [ - [[ObjectType.Song, ObjectType.Album], 'songs_albums'], - [[ObjectType.Song, ObjectType.Artist], 'songs_artists'], - [[ObjectType.Song, ObjectType.Tag], 'songs_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 { - 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; - - throw "Could not find linking table for objects: " + JSON.stringify(a) + ", " + JSON.stringify(b); -} - -// To keep track of ID fields used in linking tables. -const linkingTableIdNames: Record = { - [ObjectType.Album]: 'albumId', - [ObjectType.Artist]: 'artistId', - [ObjectType.Song]: 'songId', - [ObjectType.Tag]: 'tagId', -} - -function getRequiredDatabaseObjects(queryElem: api.QueryElem): Set { - if (queryElem.prop) { - // Leaf node. - return new Set([propertyObjects[queryElem.prop]]); - } else if (queryElem.children) { - // Branch node. - var r = new Set(); - queryElem.children.forEach((child: api.QueryElem) => { - getRequiredDatabaseObjects(child).forEach(object => r.add(object)); - }); - return r; - } - return new Set([]); -} - -function addJoin(knexQuery: any, base: ObjectType, other: ObjectType) { - const linkTable = getLinkingTable(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] }); -} - -enum WhereType { - And = 0, - Or, -}; - -function getSQLValue(val: any) { - console.log("Value:", val) - if (typeof val === 'string') { - return `'${val}'`; - } else if (typeof val === 'number') { - return `${val}`; - } - throw new Error("unimplemented SQL value type."); -} - -function getSQLValues(vals: any[]) { - if (vals.length === 0) { return '()' } - let r = `(${getSQLValue(vals[0])}`; - for (let i: number = 1; i < vals.length; i++) { - r += `, ${getSQLValue(vals[i])}`; - } - r += ')'; - return r; -} - -function getLeafWhere(queryElem: api.QueryElem): string { - const simpleLeafOps: Record = { - [api.QueryFilterOp.Eq]: "=", - [api.QueryFilterOp.Ne]: "!=", - [api.QueryFilterOp.Like]: "LIKE", - } - - const propertyKeys = { - [api.QueryElemProperty.songTitle]: '`songs`.`title`', - [api.QueryElemProperty.songId]: '`songs`.`id`', - [api.QueryElemProperty.artistName]: '`artists`.`name`', - [api.QueryElemProperty.artistId]: '`artists`.`id`', - [api.QueryElemProperty.albumName]: '`albums`.`name`', - [api.QueryElemProperty.albumId]: '`albums`.`id`', - [api.QueryElemProperty.tagId]: '`tags`.`id`', - [api.QueryElemProperty.songStoreLinks]: '`songs`.`storeLinks`', - [api.QueryElemProperty.artistStoreLinks]: '`artists`.`storeLinks`', - [api.QueryElemProperty.albumStoreLinks]: '`albums`.`storeLinks`', - } - - if (!queryElem.propOperator) throw "Cannot create where clause without an operator."; - const operator = queryElem.propOperator || api.QueryFilterOp.Eq; - const a = queryElem.prop && propertyKeys[queryElem.prop]; - const b = operator === api.QueryFilterOp.Like ? - '%' + (queryElem.propOperand || "") + '%' - : (queryElem.propOperand || ""); - - if (Object.keys(simpleLeafOps).includes(operator)) { - return `(${a} ${simpleLeafOps[operator]} ${getSQLValue(b)})`; - } else if (operator == api.QueryFilterOp.In) { - return `(${a} IN ${getSQLValues(b)})` - } else if (operator == api.QueryFilterOp.NotIn) { - return `(${a} NOT IN ${getSQLValues(b)})` - } - - throw "Query filter not implemented"; -} - -function getNodeWhere(queryElem: api.QueryElem): string { - let ops = { - [api.QueryElemOp.And]: 'AND', - [api.QueryElemOp.Or]: 'OR', - [api.QueryElemOp.Not]: 'NOT', - } - let buildList = (subqueries: api.QueryElem[], operator: api.QueryElemOp) => { - if (subqueries.length === 0) { return 'true' } - let r = `(${getWhere(subqueries[0])}`; - for (let i: number = 1; i < subqueries.length; i++) { - r += ` ${ops[operator]} ${getWhere(subqueries[i])}`; - } - r += ')'; - return r; - } - - if (queryElem.children && queryElem.childrenOperator && queryElem.children.length) { - if (queryElem.childrenOperator === api.QueryElemOp.And || - queryElem.childrenOperator === api.QueryElemOp.Or) { - return buildList(queryElem.children, queryElem.childrenOperator) - } else if (queryElem.childrenOperator === api.QueryElemOp.Not && - queryElem.children.length === 1) { - return `NOT ${getWhere(queryElem.children[0])}` - } - } - - throw new Error('invalid query') -} - -function getWhere(queryElem: api.QueryElem): string { - if (queryElem.prop) { return getLeafWhere(queryElem); } - if (queryElem.children) { return getNodeWhere(queryElem); } - return "true"; -} - -const objectColumns = { - [ObjectType.Song]: ['songs.id as songs.id', 'songs.title as songs.title', 'songs.storeLinks as songs.storeLinks'], - [ObjectType.Artist]: ['artists.id as artists.id', 'artists.name as artists.name', 'artists.storeLinks as artists.storeLinks'], - [ObjectType.Album]: ['albums.id as albums.id', 'albums.name as albums.name', 'albums.storeLinks as albums.storeLinks'], - [ObjectType.Tag]: ['tags.id as tags.id', 'tags.name as tags.name', 'tags.parentId as tags.parentId'] -}; - -function constructQuery(knex: Knex, userId: number, queryFor: ObjectType, queryElem: api.QueryElem, ordering: api.Ordering, - offset: number, limit: number | null) { - const joinObjects = getRequiredDatabaseObjects(queryElem); - joinObjects.delete(queryFor); // We are already querying this object in the base query. - - // Figure out what data we want to select from the results. - var columns: any[] = objectColumns[queryFor]; - - // TODO: there was a line here to add columns for the joined objects. - // Could not get it to work with Postgres, which wants aggregate functions - // to specify exactly how duplicates should be aggregated. - // Not sure whether we need these columns in the first place. - // joinObjects.forEach((obj: ObjectType) => columns.push(...objectColumns[obj])); - - // First, we create a base query for the type of object we need to yield. - var q = knex.select(columns) - .where({ [objectTables[queryFor] + '.user']: userId }) - .groupBy(objectTables[queryFor] + '.' + 'id') - .from(objectTables[queryFor]); - - // Now, we need to add join statements for other objects we want to filter on. - joinObjects.forEach((object: ObjectType) => { - q = addJoin(q, queryFor, object); - }) - - // Apply filtering. - q = q.andWhereRaw(getWhere(queryElem)); - - // Apply ordering - const orderKeys = { - [api.OrderByType.Name]: objectTables[queryFor] + '.' + ((queryFor === ObjectType.Song) ? 'title' : 'name') - }; - q = q.orderBy(orderKeys[ordering.orderBy.type], - (ordering.ascending ? 'asc' : 'desc')); - - // Apply limiting. - if (limit !== null) { - q = q.limit(limit) - } - - // Apply offsetting. - q = q.offset(offset); - - return q; -} - -async function getLinkedObjects(knex: Knex, userId: number, base: ObjectType, linked: ObjectType, baseIds: number[]) { - var result: Record = {}; - const otherTable = objectTables[linked]; - const linkingTable = getLinkingTable(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("Query results for", baseIds, ":", result); - return result; -} - -// Resolve a tag into the full nested structure of its ancestors. -async function getFullTag(knex: Knex, userId: number, tag: any): Promise { - const resolveTag = async (t: any) => { - if (t['tags.parentId']) { - const parent = (await knex.select(objectColumns[ObjectType.Tag]) - .from('tags') - .where({ 'user': userId }) - .where({ [objectTables[ObjectType.Tag] + '.id']: t['tags.parentId'] }))[0]; - t.parent = await resolveTag(parent); - } - return t; - } - - return await resolveTag(tag); -} +import { doQuery } from '../db/Query'; export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => { if (!api.checkQueryRequest(req.body)) { const e: EndpointError = { - internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid Query request: ' + JSON.stringify(req.body), httpStatus: 400 }; throw e; @@ -286,164 +18,13 @@ export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => console.log("User ", userId, ": Query ", reqObject); try { - const songLimit = reqObject.offsetsLimits.songLimit; - const songOffset = reqObject.offsetsLimits.songOffset; - const tagLimit = reqObject.offsetsLimits.tagLimit; - const tagOffset = reqObject.offsetsLimits.tagOffset; - const artistLimit = reqObject.offsetsLimits.artistLimit; - const artistOffset = reqObject.offsetsLimits.artistOffset; - const albumLimit = reqObject.offsetsLimits.albumLimit; - const albumOffset = reqObject.offsetsLimits.albumOffset; - - const artistsPromise: Promise = (artistLimit && artistLimit !== 0) ? - constructQuery(knex, - userId, - ObjectType.Artist, - reqObject.query, - reqObject.ordering, - artistOffset || 0, - artistLimit >= 0 ? artistLimit : null, - ) : - (async () => [])(); - - const albumsPromise: Promise = (albumLimit && albumLimit !== 0) ? - constructQuery(knex, - userId, - ObjectType.Album, - reqObject.query, - reqObject.ordering, - artistOffset || 0, - albumLimit >= 0 ? albumLimit : null, - ) : - (async () => [])(); - - const songsPromise: Promise = (songLimit && songLimit !== 0) ? - constructQuery(knex, - userId, - ObjectType.Song, - reqObject.query, - reqObject.ordering, - songOffset || 0, - songLimit >= 0 ? songLimit : null, - ) : - (async () => [])(); - - const tagsPromise: Promise = (tagLimit && tagLimit !== 0) ? - constructQuery(knex, - userId, - ObjectType.Tag, - reqObject.query, - reqObject.ordering, - tagOffset || 0, - tagLimit >= 0 ? tagLimit : null, - ) : - (async () => [])(); - - // For some objects, we want to return linked information as well. - // For that we need to do further queries. - const songIdsPromise = (async () => { - const songs = await songsPromise; - const ids = songs.map((song: any) => song['songs.id']); - return ids; - })(); - const songsArtistsPromise: Promise> = (songLimit && songLimit !== 0) ? - (async () => { - return await getLinkedObjects(knex, userId, ObjectType.Song, ObjectType.Artist, await songIdsPromise); - })() : - (async () => { return {}; })(); - const songsTagsPromise: Promise> = (songLimit && songLimit !== 0) ? - (async () => { - const tagsPerSong: Record = await getLinkedObjects(knex, userId, ObjectType.Song, ObjectType.Tag, await songIdsPromise); - var result: Record = {}; - for (var key in tagsPerSong) { - const tags = tagsPerSong[key]; - var fullTags: any[] = []; - for (var idx in tags) { - fullTags.push(await getFullTag(knex, userId, tags[idx])); - } - result[key] = fullTags; - } - return result; - })() : - (async () => { return {}; })(); - const songsAlbumsPromise: Promise> = (songLimit && songLimit !== 0) ? - (async () => { - return await getLinkedObjects(knex, userId, ObjectType.Song, ObjectType.Album, await songIdsPromise); - })() : - (async () => { return {}; })(); - - const [ - songs, - artists, - albums, - tags, - songsArtists, - songsTags, - songsAlbums, - ] = - await Promise.all([ - songsPromise, - artistsPromise, - albumsPromise, - tagsPromise, - songsArtistsPromise, - songsTagsPromise, - songsAlbumsPromise, - ]); - - var response: api.QueryResponse = { - songs: [], - artists: [], - albums: [], - tags: [], - }; - - switch (reqObject.responseType) { - case api.QueryResponseType.Details: { - response = { - songs: songs.map((song: any) => { - const id = song['songs.id']; - return toApiSong(song, songsArtists[id], songsTags[id], songsAlbums[id]); - }), - artists: artists.map((artist: any) => { - return toApiArtist(artist); - }), - albums: albums.map((album: any) => { - return toApiAlbum(album); - }), - tags: tags.map((tag: any) => { - return toApiTag(tag); - }), - }; - break; - } - case api.QueryResponseType.Ids: { - response = { - songs: songs.map((song: any) => song['songs.id']), - artists: artists.map((artist: any) => artist['artists.id']), - albums: albums.map((album: any) => album['albums.id']), - tags: tags.map((tag: any) => tag['tags.id']), - }; - break; - } - case api.QueryResponseType.Count: { - response = { - songs: songs.length, - artists: artists.length, - albums: albums.length, - tags: tags.length, - }; - break; - } - default: { - throw new Error("Unimplemented response type.") - } - } - - console.log("Query repsonse", response); - - res.send(response); + let r = doQuery(userId, reqObject, knex); + res.status(200).send(r); } catch (e) { - catchUnhandledErrors(e); + handleErrorsInEndpoint(e); } -} \ No newline at end of file +} + +export const queryEndpoints: [ string, string, boolean, EndpointHandler ][] = [ + [ api.QueryEndpoint, 'post', true, Query ], + ]; \ No newline at end of file diff --git a/server/endpoints/RegisterUser.ts b/server/endpoints/RegisterUser.ts deleted file mode 100644 index 1b4d824..0000000 --- a/server/endpoints/RegisterUser.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; - -import { sha512 } from 'js-sha512'; - -export const RegisterUser: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkRegisterUserRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid RegisterUser request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.RegisterUserRequest = req.body; - - console.log("Register User: ", reqObject); - - await knex.transaction(async (trx) => { - try { - // check if the user already exists - const user = (await trx - .select('id') - .from('users') - .where({ email: reqObject.email }))[0]; - if(user) { - res.status(400).send(); - return; - } - - // Create the new user. - const passwordHash = sha512(reqObject.password); - const userId = (await trx('users') - .insert({ - email: reqObject.email, - passwordHash: passwordHash, - }) - .returning('id') // Needed for Postgres - )[0]; - - // 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/Song.ts b/server/endpoints/Song.ts deleted file mode 100644 index 03b8479..0000000 --- a/server/endpoints/Song.ts +++ /dev/null @@ -1,382 +0,0 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; -import Knex from 'knex'; -import asJson from '../lib/asJson'; - -export const PostSong: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateSongRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid PostSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.CreateSongRequest = req.body; - const { id: userId } = req.user; - - console.log("User ", userId, ": Post Song ", 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 [] })(); - - // Start retrieving albums. - const albumIdsPromise = reqObject.albumIds ? - trx.select('id') - .from('albums') - .where({ 'user': userId }) - .whereIn('id', reqObject.albumIds) - .then((as: any) => as.map((a: any) => a['id'])) : - (async () => { return [] })(); - - // Wait for the requests to finish. - var [artists, tags, albums] = await Promise.all([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)) { - const e: EndpointError = { - internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the song. - const songId = (await trx('songs') - .insert({ - title: reqObject.title, - 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 Promise.all( - artists.map((artistId: number) => { - return trx('songs_artists').insert({ - artistId: artistId, - songId: songId, - }) - }) - ) - } - - // Link the tags via the linking table. - if (tags && tags.length) { - await Promise.all( - tags.map((tagId: number) => { - return trx('songs_tags').insert({ - songId: songId, - tagId: tagId, - }) - }) - ) - } - - // Link the albums via the linking table. - if (albums && albums.length) { - await Promise.all( - albums.map((albumId: number) => { - return trx('songs_albums').insert({ - songId: songId, - albumId: albumId, - }) - }) - ) - } - - // Respond to the request. - const responseObject: api.CreateSongResponse = { - id: songId - }; - res.status(200).send(responseObject); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} - -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 { id: userId } = req.user; - - try { - 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) - } -} - - -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 reqObject: api.ModifySongRequest = req.body; - const { id: userId } = req.user; - - 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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Tag.ts b/server/endpoints/Tag.ts index 673ec0d..6e82dab 100644 --- a/server/endpoints/Tag.ts +++ b/server/endpoints/Tag.ts @@ -1,306 +1,124 @@ -import * as api from '../../client/src/api'; -import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +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'; export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkCreateTagRequest(req)) { + if (!api.checkPostTagRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PostTag request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PostTag request', httpStatus: 400 }; throw e; } - const reqObject: api.CreateTagRequest = req.body; + const reqObject: api.PostTagRequest = req.body; const { id: userId } = req.user; console.log("User ", userId, ": Post Tag ", reqObject); - await knex.transaction(async (trx) => { - try { - // If applicable, retrieve the parent tag. - const maybeParent: number | undefined = - reqObject.parentId ? - (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': reqObject.parentId }))[0]['id'] : - undefined; - - // Check if the parent was found, if applicable. - if (reqObject.parentId && maybeParent !== reqObject.parentId) { - const e: EndpointError = { - internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Create the new tag. - var tag: any = { - name: reqObject.name, - user: userId, - }; - if (maybeParent) { - tag['parentId'] = maybeParent; - } - const tagId = (await trx('tags') - .insert(tag) - .returning('id') // Needed for Postgres - )[0]; - - // Respond to the request. - const responseObject: api.CreateTagResponse = { - id: tagId - }; - res.status(200).send(responseObject); + try { + // Respond to the request. + const responseObject: api.PostTagResponse = { + id: await createTag(userId, reqObject, knex) + }; + res.status(200).send(responseObject); - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) + } catch (e) { + handleErrorsInEndpoint(e); + } } -async function getChildrenRecursive(id: number, userId: number, trx: any) { - const directChildren = (await trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'parentId': id })).map((r: any) => r.id); - - const indirectChildrenPromises = directChildren.map( - (child: number) => getChildrenRecursive(child, userId, trx) - ); - const indirectChildrenNested = await Promise.all(indirectChildrenPromises); - const indirectChildren = indirectChildrenNested.flat(); - - return [ - ...directChildren, - ...indirectChildren, - ] -} export const DeleteTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkDeleteTagRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const reqObject: api.DeleteTagRequest = req.body; const { id: userId } = req.user; + console.log("User ", userId, ": Delete Tag ", req.params.id); - console.log("User ", userId, ": Delete Tag ", reqObject); - - await knex.transaction(async (trx) => { - try { - // Start retrieving any child tags. - const childTagsPromise = - getChildrenRecursive(req.params.id, userId, trx); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .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: EndpointError = { - internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Delete the tag and its children. - await trx('tags') - .where({ 'user': userId }) - .whereIn('id', toDelete) - .del(); + try { - // Respond to the request. - res.status(200).send(); + deleteTag(userId, req.params.id, knex); + res.status(200).send(); - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) + } catch (e) { + handleErrorsInEndpoint(e); + } } export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkTagDetailsRequest(req)) { - const e: EndpointError = { - internalMessage: 'Invalid GetTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - const { id: userId } = req.user; - try { - const results = await knex.select(['id', 'name', 'parentId']) - .from('tags') - .where({ 'user': userId }) - .where({ 'id': req.params.id }); - - if (results[0]) { - const response: api.TagDetailsResponse = { - name: results[0].name, - parentId: results[0].parentId || undefined, - } - await res.send(response); - } else { - await res.status(404).send({}); - } + let tag = await getTag(req.params.id, userId, knex); + await res.status(200).send(tag); } catch (e) { - catchUnhandledErrors(e) + handleErrorsInEndpoint(e) } } export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkModifyTagRequest(req)) { + if (!api.checkPutTagRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid PutTag request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PutTag request', httpStatus: 400 }; throw e; } - const reqObject: api.ModifyTagRequest = req.body; + const reqObject: api.PutTagRequest = req.body; const { id: userId } = req.user; console.log("User ", userId, ": Put Tag ", reqObject); - await knex.transaction(async (trx) => { - try { - // Start retrieving the parent tag. - const parentTagPromise = reqObject.parentId ? - trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ 'id': reqObject.parentId }) - .then((ts: any) => ts.map((t: any) => t['tagId'])) : - (async () => { return [] })(); - - // Start retrieving the tag itself. - const tagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: req.params.id }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) - - // Wait for the requests to finish. - var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);; - - // Check that we found all objects we need. - if ((reqObject.parentId && !parent) || - !tag) { - const e: EndpointError = { - internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } - - // Modify the tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': req.params.id }) - .update({ - name: reqObject.name, - parentId: reqObject.parentId || null, - }) - - // Respond to the request. - res.status(200).send(); - - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) + try { + await modifyTag(userId, req.params.id, reqObject, knex); + res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } } -export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { - if (!api.checkMergeTagRequest(req)) { +export const PatchTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPatchTagRequest(req)) { const e: EndpointError = { - internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), + name: "EndpointError", + message: 'Invalid PatchTag request', httpStatus: 400 }; throw e; } - const reqObject: api.DeleteTagRequest = req.body; + const reqObject: api.PatchTagRequest = req.body; const { id: userId } = req.user; - console.log("User ", userId, ": Merge Tag ", reqObject); - const fromId = req.params.id; - const toId = req.params.toId; - - await knex.transaction(async (trx) => { - try { - // Start retrieving the "from" tag. - const fromTagPromise = 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 toTagPromise = trx.select('id') - .from('tags') - .where({ 'user': userId }) - .where({ id: toId }) - .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + console.log("User ", userId, ": Patch Tag ", reqObject); - // Wait for the requests to finish. - var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); + try { + await modifyTag(userId, req.params.id, reqObject, knex); + res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} - // Check that we found all objects we need. - if (!fromTag || !toTag) { - const e: EndpointError = { - internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; - } +export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { + const { id: userId } = req.user; - // 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]); + console.log("User ", userId, ": Merge Tag ", req.params.id, req.params.toId); + const fromId = req.params.id; + const toId = req.params.toId; - // Delete the original tag. - await trx('tags') - .where({ 'user': userId }) - .where({ 'id': fromId }) - .del(); + try { + mergeTag(userId, fromId, toId, knex); + res.status(200).send(); - // Respond to the request. - res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} - } catch (e) { - catchUnhandledErrors(e); - trx.rollback(); - } - }) -} \ No newline at end of file +export const tagEndpoints: [ string, string, boolean, EndpointHandler ][] = [ + [ api.PostTagEndpoint, 'post', true, PostTag ], + [ api.GetTagEndpoint, 'get', true, GetTag ], + [ api.PutTagEndpoint, 'put', true, PutTag ], + [ api.PatchTagEndpoint, 'patch', true, PatchTag ], + [ api.DeleteTagEndpoint, 'delete', true, DeleteTag ], + [ api.MergeTagEndpoint, 'post', true, MergeTag ], + ]; \ No newline at end of file diff --git a/server/endpoints/Track.ts b/server/endpoints/Track.ts new file mode 100644 index 0000000..cc649aa --- /dev/null +++ b/server/endpoints/Track.ts @@ -0,0 +1,106 @@ +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; +import Knex from 'knex'; +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)) { + const e: EndpointError = { + name: "EndpointError", + message: 'Invalid PostTrack request', + httpStatus: 400 + }; + throw e; + } + const reqObject: api.PostTrackRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Post Track ", reqObject); + + try { + res.status(200).send({ + id: await createTrack(userId, reqObject, knex) + }); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const GetTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { + const { id: userId } = req.user; + + try { + let track = await getTrack(req.params.id, userId, knex); + await res.status(200).send(track); + } catch (e) { + handleErrorsInEndpoint(e) + } +} + +export const PutTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPutTrackRequest(req)) { + const e: EndpointError = { + name: "EndpointError", + message: 'Invalid PutTrack request', + httpStatus: 400 + }; + throw e; + } + const reqObject: api.PutTrackRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Put Track ", reqObject); + + try { + modifyTrack(userId, req.params.id, reqObject, knex); + res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const PatchTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkPatchTrackRequest(req)) { + const e: EndpointError = { + name: "EndpointError", + message: 'Invalid PatchTrack request', + httpStatus: 400 + }; + throw e; + } + const reqObject: api.PatchTrackRequest = req.body; + const { id: userId } = req.user; + + console.log("User ", userId, ": Patch Track ", reqObject); + + try { + modifyTrack(userId, req.params.id, reqObject, knex); + res.status(200).send(); + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const DeleteTrack: EndpointHandler = async (req: any, res: any, knex: Knex) => { + const { id: userId } = req.user; + + console.log("User ", userId, ": Delete Track ", req.params.id); + + try { + await deleteTrack(userId, req.params.id, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const trackEndpoints: [ string, string, boolean, EndpointHandler ][] = [ + [ api.PostTrackEndpoint, 'post', true, PostTrack ], + [ api.GetTrackEndpoint, 'get', true, GetTrack ], + [ api.PutTrackEndpoint, 'put', true, PutTrack ], + [ api.PatchTrackEndpoint, 'patch', true, PatchTrack ], + [ api.DeleteTrackEndpoint, 'delete', true, DeleteTrack ], +]; \ No newline at end of file diff --git a/server/endpoints/User.ts b/server/endpoints/User.ts new file mode 100644 index 0000000..cddb3de --- /dev/null +++ b/server/endpoints/User.ts @@ -0,0 +1,31 @@ +import * as api from '../../client/src/api/api'; +import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types'; +import Knex from 'knex'; + +import { sha512 } from 'js-sha512'; +import { createUser } from '../db/User'; + +export const RegisterUser: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkRegisterUserRequest(req)) { + const e: EndpointError = { + name: "EndpointError", + message: 'Invalid RegisterUser request', + httpStatus: 400 + }; + throw e; + } + const reqObject: api.RegisterUserRequest = req.body; + + console.log("Register User: ", reqObject); + try { + await createUser(reqObject, knex); + res.status(200).send(); + + } catch (e) { + handleErrorsInEndpoint(e); + } +} + +export const userEndpoints: [ string, string, boolean, EndpointHandler ][] = [ + [ api.RegisterUserEndpoint, 'post', false, RegisterUser ], + ]; \ No newline at end of file diff --git a/server/endpoints/types.ts b/server/endpoints/types.ts index a2bd28e..01d7108 100644 --- a/server/endpoints/types.ts +++ b/server/endpoints/types.ts @@ -2,26 +2,49 @@ import Knex from 'knex'; export type EndpointHandler = (req: any, res: any, knex: Knex) => Promise; -export interface EndpointError { - internalMessage: String; +export interface EndpointError extends Error { + name: "EndpointError", + message: string; httpStatus: Number; } +export enum DBErrorKind { + Unknown = "Unknown", + ResourceNotFound = "ResourceNotFound", + ResourceConflict = "ResourceConflict", +} + +export interface DBError extends Error { + name: "DBError", + kind: DBErrorKind, + message: string, +} + export function isEndpointError(obj: any): obj is EndpointError { - return obj.internalMessage !== undefined && - obj.httpStatus !== undefined; + return obj.name === "EndpointError"; } -export const catchUnhandledErrors = (_e: any) => { - if (isEndpointError(_e)) { - // Rethrow - throw _e; +export function isDBError(obj: any): obj is DBError { + return obj.name === "DBError"; +} + +export function toEndpointError(e: Error): EndpointError { + if (isEndpointError(e)) { return e; } + else if (isDBError(e) && e.kind === DBErrorKind.ResourceNotFound) { + return { + name: "EndpointError", + message: e.message, + httpStatus: 404, + } } - // This is an unhandled error, make an internal server error out of it. - const e: EndpointError = { - internalMessage: _e, - httpStatus: 500 + return { + name: "EndpointError", + message: e.message, + httpStatus: 500, } - throw e; +} + +export const handleErrorsInEndpoint = (_e: any) => { + throw toEndpointError(_e); } \ No newline at end of file diff --git a/server/integrations/integrations.ts b/server/integrations/integrations.ts index 93ef2d3..b9988e0 100644 --- a/server/integrations/integrations.ts +++ b/server/integrations/integrations.ts @@ -1,5 +1,5 @@ import Knex from "knex"; -import { IntegrationType } from "../../client/src/api"; +import { IntegrationImpl } from "../../client/src/api/api"; const { createProxyMiddleware } = require('http-proxy-middleware'); let axios = require('axios') @@ -80,7 +80,7 @@ export function createIntegrations(knex: Knex) { req._integration.secretDetails = JSON.parse(req._integration.secretDetails); switch (req._integration.type) { - case IntegrationType.SpotifyClientCredentials: { + case IntegrationImpl.SpotifyClientCredentials: { console.log("Integration: ", req._integration) // FIXME: persist the token req._access_token = await getSpotifyCCAuthToken( @@ -93,7 +93,7 @@ export function createIntegrations(knex: Knex) { req.headers["Authorization"] = "Bearer " + req._access_token; return proxySpotifyCC(req, res, next); } - case IntegrationType.YoutubeWebScraper: { + case IntegrationImpl.YoutubeWebScraper: { console.log("Integration: ", req._integration) return proxyYoutubeMusic(req, res, next); } diff --git a/server/lib/dbToApi.ts b/server/lib/dbToApi.ts deleted file mode 100644 index 3495058..0000000 --- a/server/lib/dbToApi.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as api from '../../client/src/api'; -import asJson from './asJson'; - -export function toApiTag(dbObj: any): api.TagDetails { - return { - tagId: dbObj['tags.id'], - name: dbObj['tags.name'], - parentId: dbObj['tags.parentId'], - parent: dbObj.parent ? toApiTag(dbObj.parent) : undefined, - }; -} - -export function toApiArtist(dbObj: any) { - return { - artistId: dbObj['artists.id'], - name: dbObj['artists.name'], - storeLinks: asJson(dbObj['artists.storeLinks']), - }; -} - -export function toApiSong(dbObj: any, artists: any[], tags: any[], albums: any[]) { - return { - songId: dbObj['songs.id'], - title: dbObj['songs.title'], - storeLinks: asJson(dbObj['songs.storeLinks']), - artists: artists.map((artist: any) => { - return toApiArtist(artist); - }), - tags: tags.map((tag: any) => { - return toApiTag(tag); - }), - albums: albums.map((album: any) => { - return toApiAlbum(album); - }), - } -} - -export function toApiAlbum(dbObj: any) { - return { - albumId: dbObj['albums.id'], - name: dbObj['albums.name'], - storeLinks: asJson(dbObj['albums.storeLinks']), - }; -} \ No newline at end of file diff --git a/server/migrations/20200828124218_init_db.ts b/server/migrations/20200828124218_init_db.ts index b9883e2..70fc0ab 100644 --- a/server/migrations/20200828124218_init_db.ts +++ b/server/migrations/20200828124218_init_db.ts @@ -2,13 +2,15 @@ import * as Knex from "knex"; export async function up(knex: Knex): Promise { - // Songs table. + // tracks table. await knex.schema.createTable( - 'songs', + 'tracks', (table: any) => { table.increments('id'); - table.string('title'); - table.json('storeLinks') + table.string('name'); + table.string('storeLinks') + table.integer('user').unsigned().notNullable().defaultTo(1); + table.integer('album').unsigned().defaultTo(null); } ) @@ -18,7 +20,8 @@ export async function up(knex: Knex): Promise { (table: any) => { table.increments('id'); table.string('name'); - table.json('storeLinks'); + table.string('storeLinks'); + table.integer('user').unsigned().notNullable().defaultTo(1); } ) @@ -28,7 +31,8 @@ export async function up(knex: Knex): Promise { (table: any) => { table.increments('id'); table.string('name'); - table.json('storeLinks'); + table.string('storeLinks'); + table.integer('user').unsigned().notNullable().defaultTo(1); } ) @@ -39,36 +43,53 @@ export async function up(knex: Knex): Promise { table.increments('id'); table.string('name'); table.integer('parentId'); + table.integer('user').unsigned().notNullable().defaultTo(1); } ) - // Songs <-> Artists + // Users table. await knex.schema.createTable( - 'songs_artists', + 'users', (table: any) => { table.increments('id'); - table.integer('songId'); - table.integer('artistId'); + table.string('email'); + table.string('passwordHash') } ) - // Songs <-> Albums + // Integrations table. await knex.schema.createTable( - 'songs_albums', + 'integrations', (table: any) => { table.increments('id'); - table.integer('songId'); - table.integer('albumId'); + table.integer('user').unsigned().notNullable().defaultTo(1); + 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. + table.string('secretDetails'); // Stores anything that might be needed for the integration to work and which + // should never leave the server. + } + ) + + // tracks <-> Artists + await knex.schema.createTable( + 'tracks_artists', + (table: any) => { + table.increments('id'); + table.integer('trackId'); + table.integer('artistId'); + table.unique(['trackId', 'artistId']) } ) - // Songs <-> Tags + // tracks <-> Tags await knex.schema.createTable( - 'songs_tags', + 'tracks_tags', (table: any) => { table.increments('id'); - table.integer('songId'); + table.integer('trackId'); table.integer('tagId'); + table.unique(['trackId', 'tagId']) } ) @@ -79,6 +100,7 @@ export async function up(knex: Knex): Promise { table.increments('id'); table.integer('artistId'); table.integer('tagId'); + table.unique(['artistId', 'tagId']) } ) @@ -89,6 +111,7 @@ export async function up(knex: Knex): Promise { table.increments('id'); table.integer('tagId'); table.integer('albumId'); + table.unique(['albumId', 'tagId']) } ) @@ -99,21 +122,24 @@ export async function up(knex: Knex): Promise { table.increments('id'); table.integer('artistId'); table.integer('albumId'); + table.unique(['artistId', 'albumId']) } ) } export async function down(knex: Knex): Promise { - await knex.schema.dropTable('songs'); + await knex.schema.dropTable('tracks'); await knex.schema.dropTable('artists'); await knex.schema.dropTable('albums'); await knex.schema.dropTable('tags'); - await knex.schema.dropTable('songs_artists'); - await knex.schema.dropTable('songs_albums'); - await knex.schema.dropTable('songs_tags'); + await knex.schema.dropTable('tracks_artists'); + await knex.schema.dropTable('tracks_albums'); + await knex.schema.dropTable('tracks_tags'); await knex.schema.dropTable('artists_tags'); await knex.schema.dropTable('albums_tags'); await knex.schema.dropTable('artists_albums'); + await knex.schema.dropTable('users'); + await knex.schema.dropTable('integrations'); } diff --git a/server/migrations/20201110170100_add_users.ts b/server/migrations/20201110170100_add_users.ts deleted file mode 100644 index 6b75776..0000000 --- a/server/migrations/20201110170100_add_users.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as Knex from "knex"; -import { sha512 } from "js-sha512"; - - -export async function up(knex: Knex): Promise { - // Users table. - await knex.schema.createTable( - 'users', - (table: any) => { - table.increments('id'); - table.string('email'); - table.string('passwordHash') - } - ) - - // Add user column to other object tables. - await knex.schema.alterTable( - 'songs', - (table: any) => { - table.integer('user').unsigned().notNullable().defaultTo(1); - } - ) - await knex.schema.alterTable( - 'albums', - (table: any) => { - table.integer('user').unsigned().notNullable().defaultTo(1); - } - ) - await knex.schema.alterTable( - 'tags', - (table: any) => { - table.integer('user').unsigned().notNullable().defaultTo(1); - } - ) - await knex.schema.alterTable( - 'artists', - (table: any) => { - table.integer('user').unsigned().notNullable().defaultTo(1); - } - ) -} - - -export async function down(knex: Knex): Promise { - await knex.schema.dropTable('users'); - - // Remove the user column - await knex.schema.alterTable( - 'songs', - (table: any) => { - table.dropColumn('user'); - } - ) - await knex.schema.alterTable( - 'albums', - (table: any) => { - table.dropColumn('user'); - } - ) - await knex.schema.alterTable( - 'tags', - (table: any) => { - table.dropColumn('user'); - } - ) - await knex.schema.alterTable( - 'artists', - (table: any) => { - table.dropColumn('user'); - } - ) -} - diff --git a/server/migrations/20201113155620_add_integrations.ts b/server/migrations/20201113155620_add_integrations.ts deleted file mode 100644 index 08dbc43..0000000 --- a/server/migrations/20201113155620_add_integrations.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as Knex from "knex"; - - -export async function up(knex: Knex): Promise { - // Integrations table. - await knex.schema.createTable( - 'integrations', - (table: any) => { - table.increments('id'); - table.integer('user').unsigned().notNullable().defaultTo(1); - 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.json('details'); // Stores anything that might be needed for the integration to work. - table.json('secretDetails'); // Stores anything that might be needed for the integration to work and which - // should never leave the server. - } - ) -} - - -export async function down(knex: Knex): Promise { - await knex.schema.dropTable('integrations'); -} - diff --git a/server/migrations/20201126082705_storelinks_to_text.ts b/server/migrations/20201126082705_storelinks_to_text.ts deleted file mode 100644 index ac174ee..0000000 --- a/server/migrations/20201126082705_storelinks_to_text.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as Knex from "knex"; - -/* -This migration converts the storeLinks column from JSON to plain text. -The reason is that there are too many differences between the JSON support -of different back-ends, making plain text easier to deal with. -*/ - -async function castStoreLinks(table: string, knex: any) { - await knex.schema.alterTable(table, (t: any) => { - t.string('storeLinksTemp'); - }); - await knex(table).update({ - storeLinksTemp: knex.raw('CAST("storeLinks" AS VARCHAR(255))') - }) - await knex.schema.alterTable(table, (t: any) => { - t.dropColumn('storeLinks'); - }); - await knex.schema.alterTable(table, (t: any) => { - t.renameColumn('storeLinksTemp', 'storeLinks'); - }); -} - -async function revertStoreLinks(table: string, knex: any) { - await knex.schema.alterTable(table, (t: any) => { - t.json('storeLinksTemp'); - }); - if (knex.client.config.client === 'sqlite3') { - await knex(table).update({ - storeLinksTemp: knex.raw('"storeLinks"') - }) - } else { - await knex(table).update({ - storeLinksTemp: knex.raw('CAST("storeLinks" AS json)') - }) - } - await knex.schema.alterTable(table, (t: any) => { - t.dropColumn('storeLinks'); - }); - await knex.schema.alterTable(table, (t: any) => { - t.renameColumn('storeLinksTemp', 'storeLinks'); - }); -} - -export async function up(knex: Knex): Promise { - console.log("Knex client:", knex.client.config); - await castStoreLinks('songs', knex); - await castStoreLinks('albums', knex); - await castStoreLinks('artists', knex); -} - - -export async function down(knex: Knex): Promise { - await revertStoreLinks('songs', knex); - await revertStoreLinks('albums', knex); - await revertStoreLinks('artists', knex); -} - diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js index cd693c0..c32d88c 100644 --- a/server/test/integration/flows/IntegrationFlow.js +++ b/server/test/integration/flows/IntegrationFlow.js @@ -4,7 +4,7 @@ const express = require('express'); import { SetupApp } from '../../../app'; import * as helpers from './helpers'; import { sha512 } from 'js-sha512'; -import { IntegrationType } from '../../../../client/src/api'; +import { IntegrationImpl } from '../../../../client/src/api'; async function init() { chai.use(chaiHttp); @@ -30,10 +30,10 @@ describe('POST /integration with missing or wrong data', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 400); + await helpers.createIntegration(req, { type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 400); await helpers.createIntegration(req, { name: "A", details: {}, secretDetails: {} }, 400); - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, secretDetails: {} }, 400); - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, }, 400); await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400); } finally { req.close(); @@ -48,7 +48,7 @@ describe('POST /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); } finally { req.close(); agent.close(); @@ -62,9 +62,9 @@ describe('PUT /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); - await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200); - await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' } }) + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200); + await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: { secret: 'cat' } }) } finally { req.close(); agent.close(); @@ -78,7 +78,7 @@ describe('PUT /integration with wrong data', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {}, secretDetails: {} }, 400); } finally { req.close(); @@ -93,8 +93,8 @@ describe('DELETE /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); - await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }) + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {} }) await helpers.deleteIntegration(req, 1, 200); await helpers.checkIntegration(req, 1, 404); } finally { @@ -110,13 +110,13 @@ describe('GET /integration list with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); - await helpers.createIntegration(req, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 }); - await helpers.createIntegration(req, { name: "C", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 }); + await helpers.createIntegration(req, { name: "C", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 }); await helpers.listIntegrations(req, 200, [ - { id: 1, name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }, - { id: 2, name: "B", type: IntegrationType.SpotifyClientCredentials, details: {} }, - { id: 3, name: "C", type: IntegrationType.SpotifyClientCredentials, details: {} }, + { id: 1, name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {} }, + { id: 2, name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: {} }, + { id: 3, name: "C", type: IntegrationImpl.SpotifyClientCredentials, details: {} }, ]); } finally { req.close(); diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index 12677cd..91bdc27 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -1,6 +1,6 @@ import { expect } from "chai"; import { sha512 } from "js-sha512"; -import { IntegrationType } from "../../../../client/src/api"; +import { IntegrationImpl } from "../../../../client/src/api"; export async function initTestDB() { // Allow different database configs - but fall back to SQLite in memory if necessary. @@ -249,7 +249,7 @@ export async function logout( export async function createIntegration( req, - props = { name: "Integration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, + props = { name: "Integration", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, expectStatus = undefined, expectResponse = undefined ) { @@ -266,7 +266,7 @@ export async function createIntegration( export async function modifyIntegration( req, id = 1, - props = { name: "NewIntegration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, + props = { name: "NewIntegration", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, expectStatus = undefined, ) { await req