Major refactoring. It builds again!

editsong
Sander Vocke 5 years ago
parent b4c747d6f0
commit bb9a1bdfa6
  1. 476
      client/src/api.ts
  2. 13
      client/src/api/api.ts
  3. 42
      client/src/api/endpoints/auth.ts
  4. 118
      client/src/api/endpoints/query.ts
  5. 186
      client/src/api/endpoints/resources.ts
  6. 278
      client/src/api/types/resources.ts
  7. 2
      client/src/components/MainWindow.tsx
  8. 14
      client/src/components/common/StoreLinkIcon.tsx
  9. 6
      client/src/components/querybuilder/QBAddElemMenu.tsx
  10. 4
      client/src/components/querybuilder/QBLeafElem.tsx
  11. 2
      client/src/components/querybuilder/QueryBuilder.tsx
  12. 48
      client/src/components/tables/ResultsTable.tsx
  13. 22
      client/src/components/windows/Windows.tsx
  14. 54
      client/src/components/windows/album/AlbumWindow.tsx
  15. 54
      client/src/components/windows/artist/ArtistWindow.tsx
  16. 100
      client/src/components/windows/manage_links/BatchLinkDialog.tsx
  17. 32
      client/src/components/windows/manage_links/LinksStatusWidget.tsx
  18. 2
      client/src/components/windows/manage_tags/ManageTagsWindow.tsx
  19. 5
      client/src/components/windows/manage_tags/TagChange.tsx
  20. 28
      client/src/components/windows/query/QueryWindow.tsx
  21. 34
      client/src/components/windows/settings/IntegrationSettings.tsx
  22. 72
      client/src/components/windows/tag/TagWindow.tsx
  23. 48
      client/src/components/windows/track/EditTrackDialog.tsx
  24. 72
      client/src/components/windows/track/TrackWindow.tsx
  25. 7
      client/src/lib/backend/albums.tsx
  26. 7
      client/src/lib/backend/artists.tsx
  27. 17
      client/src/lib/backend/integrations.tsx
  28. 185
      client/src/lib/backend/queries.tsx
  29. 10
      client/src/lib/backend/songs.tsx
  30. 10
      client/src/lib/backend/tags.tsx
  31. 10
      client/src/lib/backend/tracks.tsx
  32. 18
      client/src/lib/integration/Integration.tsx
  33. 22
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  34. 34
      client/src/lib/integration/useIntegrations.tsx
  35. 30
      client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx
  36. 26
      client/src/lib/query/Query.tsx
  37. 16
      client/src/lib/saveChanges.tsx
  38. 28
      client/src/lib/songGetters.tsx
  39. 28
      client/src/lib/trackGetters.tsx
  40. 2
      client/src/lib/useAuth.tsx
  41. 71
      server/app.ts
  42. 405
      server/db/Album.ts
  43. 323
      server/db/Artist.ts
  44. 204
      server/db/ImportExport.ts
  45. 135
      server/db/Integration.ts
  46. 476
      server/db/Query.ts
  47. 274
      server/db/Tag.ts
  48. 343
      server/db/Track.ts
  49. 39
      server/db/User.ts
  50. 309
      server/endpoints/Album.ts
  51. 227
      server/endpoints/Artist.ts
  52. 192
      server/endpoints/Integration.ts
  53. 443
      server/endpoints/Query.ts
  54. 49
      server/endpoints/RegisterUser.ts
  55. 382
      server/endpoints/Song.ts
  56. 278
      server/endpoints/Tag.ts
  57. 106
      server/endpoints/Track.ts
  58. 31
      server/endpoints/User.ts
  59. 49
      server/endpoints/types.ts
  60. 6
      server/integrations/integrations.ts
  61. 44
      server/lib/dbToApi.ts
  62. 68
      server/migrations/20200828124218_init_db.ts
  63. 73
      server/migrations/20201110170100_add_users.ts
  64. 24
      server/migrations/20201113155620_add_integrations.ts
  65. 58
      server/migrations/20201126082705_storelinks_to_text.ts
  66. 34
      server/test/integration/flows/IntegrationFlow.js
  67. 6
      server/test/integration/flows/helpers.js

@ -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, string> = {
[ExternalStore.GooglePlayMusic]: 'play.google.com',
[ExternalStore.Spotify]: 'spotify.com',
[ExternalStore.YoutubeMusic]: 'music.youtube.com',
}
export const IntegrationStores: Record<IntegrationType, ExternalStore> = {
[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 }

@ -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';

@ -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";

@ -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);
}

@ -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;

@ -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, string> = {
[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, IntegrationWith> = {
[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,
}

@ -6,7 +6,7 @@ import QueryWindow from './windows/query/QueryWindow';
import ArtistWindow from './windows/artist/ArtistWindow'; import ArtistWindow from './windows/artist/ArtistWindow';
import AlbumWindow from './windows/album/AlbumWindow'; import AlbumWindow from './windows/album/AlbumWindow';
import TagWindow from './windows/tag/TagWindow'; 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 ManageTagsWindow from './windows/manage_tags/ManageTagsWindow';
import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom'; import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom';
import LoginWindow from './windows/login/LoginWindow'; import LoginWindow from './windows/login/LoginWindow';

@ -1,16 +1,16 @@
import React from 'react'; 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 GPMIcon } from '../../assets/googleplaymusic_icon.svg';
import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg'; import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg';
import { ReactComponent as YoutubeMusicIcon } from '../../assets/youtubemusic_icon.svg'; import { ReactComponent as YoutubeMusicIcon } from '../../assets/youtubemusic_icon.svg';
export interface IProps { export interface IProps {
whichStore: ExternalStore, whichStore: IntegrationWith,
} }
export function whichStore(url: string) { export function whichStore(url: string) {
return Object.keys(StoreURLIdentifiers).reduce((prev: string | undefined, cur: string) => { return Object.keys(IntegrationUrls).reduce((prev: string | undefined, cur: string) => {
if(url.includes(StoreURLIdentifiers[cur as ExternalStore])) { if(url.includes(IntegrationUrls[cur as IntegrationWith])) {
return cur; return cur;
} }
return prev; return prev;
@ -24,11 +24,11 @@ export default function StoreLinkIcon(props: any) {
{ height: '40px', width: '40px' } : style; { height: '40px', width: '40px' } : style;
switch (whichStore) { switch (whichStore) {
case ExternalStore.GooglePlayMusic: case IntegrationWith.GooglePlayMusic:
return <GPMIcon {...restProps} style={realStyle} />; return <GPMIcon {...restProps} style={realStyle} />;
case ExternalStore.Spotify: case IntegrationWith.Spotify:
return <SpotifyIcon {...restProps} style={realStyle} />; return <SpotifyIcon {...restProps} style={realStyle} />;
case ExternalStore.YoutubeMusic: case IntegrationWith.YoutubeMusic:
return <YoutubeMusicIcon {...restProps} style={realStyle} />; return <YoutubeMusicIcon {...restProps} style={realStyle} />;
default: default:
throw new Error("Unknown external store: " + whichStore) throw new Error("Unknown external store: " + whichStore)

@ -112,16 +112,16 @@ export function QBAddElemMenu(props: MenuProps) {
> >
<MenuItem disabled={true}>New query element</MenuItem> <MenuItem disabled={true}>New query element</MenuItem>
<NestedMenuItem <NestedMenuItem
label="Song" label="Track"
parentMenuOpen={Boolean(anchorEl)} parentMenuOpen={Boolean(anchorEl)}
> >
<QBSelectWithRequest <QBSelectWithRequest
label="Title" label="Title"
getNewOptions={props.requestFunctions.getSongTitles} getNewOptions={props.requestFunctions.getTrackNames}
onSubmit={(s: string, exact: boolean) => { onSubmit={(s: string, exact: boolean) => {
onClose(); onClose();
props.onCreateQuery({ props.onCreateQuery({
a: QueryLeafBy.SongTitle, a: QueryLeafBy.TrackName,
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s b: s
}); });

@ -138,14 +138,14 @@ export function QBLeafElem(props: IProps) {
{...props} {...props}
extraElements={extraElements} extraElements={extraElements}
/> />
} if (e.a === QueryLeafBy.SongTitle && } if (e.a === QueryLeafBy.TrackName &&
e.leafOp === QueryLeafOp.Equals && e.leafOp === QueryLeafOp.Equals &&
typeof e.b == "string") { typeof e.b == "string") {
return <QBQueryElemTitleEquals return <QBQueryElemTitleEquals
{...props} {...props}
extraElements={extraElements} extraElements={extraElements}
/> />
} else if (e.a === QueryLeafBy.SongTitle && } else if (e.a === QueryLeafBy.TrackName &&
e.leafOp === QueryLeafOp.Like && e.leafOp === QueryLeafOp.Like &&
typeof e.b == "string") { typeof e.b == "string") {
return <QBQueryElemTitleLike return <QBQueryElemTitleLike

@ -14,7 +14,7 @@ export interface TagItem {
export interface Requests { export interface Requests {
getArtists: (filter: string) => Promise<string[]>, getArtists: (filter: string) => Promise<string[]>,
getAlbums: (filter: string) => Promise<string[]>, getAlbums: (filter: string) => Promise<string[]>,
getSongTitles: (filter: string) => Promise<string[]>, getTrackNames: (filter: string) => Promise<string[]>,
getTags: () => Promise<TagItem[]>, getTags: () => Promise<TagItem[]>,
} }

@ -3,20 +3,20 @@ import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyle
import stringifyList from '../../lib/stringifyList'; import stringifyList from '../../lib/stringifyList';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
export interface SongGetters { export interface TrackGetters {
getTitle: (song: any) => string, getTitle: (track: any) => string,
getId: (song: any) => number, getId: (track: any) => number,
getArtistNames: (song: any) => string[], getArtistNames: (track: any) => string[],
getArtistIds: (song: any) => number[], getArtistIds: (track: any) => number[],
getAlbumNames: (song: any) => string[], getAlbumNames: (track: any) => string[],
getAlbumIds: (song: any) => number[], getAlbumIds: (track: any) => number[],
getTagNames: (song: any) => string[][], // Each tag is represented as a series of strings. getTagNames: (track: any) => string[][], // Each tag is represented as a series of strings.
getTagIds: (song: any) => number[][], // Each tag is represented as a series of ids. getTagIds: (track: any) => number[][], // Each tag is represented as a series of ids.
} }
export default function SongTable(props: { export default function TrackTable(props: {
songs: any[], tracks: any[],
songGetters: SongGetters, trackGetters: TrackGetters,
}) { }) {
const history = useHistory(); const history = useHistory();
@ -44,17 +44,17 @@ export default function SongTable(props: {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.songs.map((song: any) => { {props.tracks.map((track: any) => {
const title = props.songGetters.getTitle(song); const title = props.trackGetters.getTitle(track);
// TODO: display artists and albums separately! // TODO: display artists and albums separately!
const artistNames = props.songGetters.getArtistNames(song); const artistNames = props.trackGetters.getArtistNames(track);
const artist = stringifyList(artistNames); const artist = stringifyList(artistNames);
const mainArtistId = props.songGetters.getArtistIds(song)[0]; const mainArtistId = props.trackGetters.getArtistIds(track)[0];
const albumNames = props.songGetters.getAlbumNames(song); const albumNames = props.trackGetters.getAlbumNames(track);
const album = stringifyList(albumNames); const album = stringifyList(albumNames);
const mainAlbumId = props.songGetters.getAlbumIds(song)[0]; const mainAlbumId = props.trackGetters.getAlbumIds(track)[0];
const songId = props.songGetters.getId(song); const trackId = props.trackGetters.getId(track);
const tagIds = props.songGetters.getTagIds(song); const tagIds = props.trackGetters.getTagIds(track);
const onClickArtist = () => { const onClickArtist = () => {
history.push('/artist/' + mainArtistId); history.push('/artist/' + mainArtistId);
@ -64,15 +64,15 @@ export default function SongTable(props: {
history.push('/album/' + mainAlbumId); history.push('/album/' + mainAlbumId);
} }
const onClickSong = () => { const onClickTrack = () => {
history.push('/song/' + songId); history.push('/track/' + trackId);
} }
const onClickTag = (id: number, name: string) => { const onClickTag = (id: number, name: string) => {
history.push('/tag/' + id); 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) => { const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e; return (idx === 0) ? e : " / " + e;
}) })
@ -100,7 +100,7 @@ export default function SongTable(props: {
} }
return <TableRow key={title}> return <TableRow key={title}>
<TextCell align="left" _onClick={onClickSong}>{title}</TextCell> <TextCell align="left" _onClick={onClickTrack}>{title}</TextCell>
<TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell> <TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell>
<TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell> <TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell>
<TableCell padding="none" align="left" width="25%"> <TableCell padding="none" align="left" width="25%">

@ -7,10 +7,10 @@ import AlbumIcon from '@material-ui/icons/Album';
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import LoyaltyIcon from '@material-ui/icons/Loyalty'; 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 AlbumWindow, { AlbumWindowReducer } from './album/AlbumWindow';
import TagWindow, { TagWindowReducer } from './tag/TagWindow'; import TagWindow, { TagWindowReducer } from './tag/TagWindow';
import { songGetters } from '../../lib/songGetters'; import { trackGetters } from '../../lib/trackGetters';
import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow'; import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow';
import { RegisterWindowReducer } from './register/RegisterWindow'; import { RegisterWindowReducer } from './register/RegisterWindow';
import { LoginWindowReducer } from './login/LoginWindow'; import { LoginWindowReducer } from './login/LoginWindow';
@ -21,7 +21,7 @@ export enum WindowType {
Artist = "Artist", Artist = "Artist",
Album = "Album", Album = "Album",
Tag = "Tag", Tag = "Tag",
Song = "Song", Track = "Track",
ManageTags = "ManageTags", ManageTags = "ManageTags",
Login = "Login", Login = "Login",
Register = "Register", Register = "Register",
@ -36,7 +36,7 @@ export const newWindowReducer = {
[WindowType.Query]: QueryWindowReducer, [WindowType.Query]: QueryWindowReducer,
[WindowType.Artist]: ArtistWindowReducer, [WindowType.Artist]: ArtistWindowReducer,
[WindowType.Album]: AlbumWindowReducer, [WindowType.Album]: AlbumWindowReducer,
[WindowType.Song]: SongWindowReducer, [WindowType.Track]: TrackWindowReducer,
[WindowType.Tag]: TagWindowReducer, [WindowType.Tag]: TagWindowReducer,
[WindowType.ManageTags]: ManageTagsWindowReducer, [WindowType.ManageTags]: ManageTagsWindowReducer,
[WindowType.Login]: LoginWindowReducer, [WindowType.Login]: LoginWindowReducer,
@ -59,8 +59,8 @@ export const newWindowState = {
id: 1, id: 1,
metadata: null, metadata: null,
pendingChanges: null, pendingChanges: null,
songGetters: songGetters, trackGetters: trackGetters,
songsByArtist: null, tracksByArtist: null,
} }
}, },
[WindowType.Album]: () => { [WindowType.Album]: () => {
@ -68,11 +68,11 @@ export const newWindowState = {
id: 1, id: 1,
metadata: null, metadata: null,
pendingChanges: null, pendingChanges: null,
songGetters: songGetters, trackGetters: trackGetters,
songsOnAlbum: null, tracksOnAlbum: null,
} }
}, },
[WindowType.Song]: () => { [WindowType.Track]: () => {
return { return {
id: 1, id: 1,
metadata: null, metadata: null,
@ -84,8 +84,8 @@ export const newWindowState = {
id: 1, id: 1,
metadata: null, metadata: null,
pendingChanges: null, pendingChanges: null,
songGetters: songGetters, trackGetters: trackGetters,
songsWithTag: null, tracksWithTag: null,
} }
}, },
[WindowType.ManageTags]: () => { [WindowType.ManageTags]: () => {

@ -1,35 +1,35 @@
import React, { useEffect, useState, useReducer } from 'react'; import React, { useEffect, useState, useReducer } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import AlbumIcon from '@material-ui/icons/Album'; import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../../api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable'; import TrackTable, { TrackGetters } from '../../tables/ResultsTable';
import { modifyAlbum } from '../../../lib/saveChanges'; import { modifyAlbum } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries'; import { queryAlbums, queryTracks } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters'; import { trackGetters } from '../../../lib/trackGetters';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
export type AlbumMetadata = serverApi.AlbumDetails; export type AlbumMetadata = serverApi.AlbumWithId;
export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest; export type AlbumMetadataChanges = serverApi.PatchAlbumRequest;
export interface AlbumWindowState extends WindowState { export interface AlbumWindowState extends WindowState {
id: number, id: number,
metadata: AlbumMetadata | null, metadata: AlbumMetadata | null,
pendingChanges: AlbumMetadataChanges | null, pendingChanges: AlbumMetadataChanges | null,
songsOnAlbum: any[] | null, tracksOnAlbum: any[] | null,
songGetters: SongGetters, trackGetters: TrackGetters,
} }
export enum AlbumWindowStateActions { export enum AlbumWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges", SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs", SetTracks = "SetTracks",
Reload = "Reload", Reload = "Reload",
} }
@ -39,10 +39,10 @@ export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case AlbumWindowStateActions.SetPendingChanges: case AlbumWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value } return { ...state, pendingChanges: action.value }
case AlbumWindowStateActions.SetSongs: case AlbumWindowStateActions.SetTracks:
return { ...state, songsOnAlbum: action.value } return { ...state, tracksOnAlbum: action.value }
case AlbumWindowStateActions.Reload: case AlbumWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsOnAlbum: null } return { ...state, metadata: null, pendingChanges: null, tracksOnAlbum: null }
default: default:
throw new Error("Unimplemented AlbumWindow state update.") throw new Error("Unimplemented AlbumWindow state update.")
} }
@ -65,8 +65,8 @@ export default function AlbumWindow(props: {}) {
id: parseInt(id), id: parseInt(id),
metadata: null, metadata: null,
pendingChanges: null, pendingChanges: null,
songGetters: songGetters, trackGetters: trackGetters,
songsOnAlbum: null, tracksOnAlbum: null,
}); });
return <AlbumWindowControlled state={state} dispatch={dispatch} /> return <AlbumWindowControlled state={state} dispatch={dispatch} />
@ -76,7 +76,7 @@ export function AlbumWindowControlled(props: {
state: AlbumWindowState, state: AlbumWindowState,
dispatch: (action: any) => void, dispatch: (action: any) => void,
}) { }) {
let { id: albumId, metadata, pendingChanges, songsOnAlbum } = props.state; let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state;
let { dispatch } = props; let { dispatch } = props;
let auth = useAuth(); let auth = useAuth();
@ -92,12 +92,12 @@ export function AlbumWindowControlled(props: {
.catch((e: any) => { handleNotLoggedIn(auth, e) }) .catch((e: any) => { handleNotLoggedIn(auth, e) })
}, [albumId, dispatch]); }, [albumId, dispatch]);
// Effect to get the album's songs. // Effect to get the album's tracks.
useEffect(() => { useEffect(() => {
if (songsOnAlbum) { return; } if (tracksOnAlbum) { return; }
(async () => { (async () => {
const songs = await querySongs( const tracks = await queryTracks(
{ {
a: QueryLeafBy.AlbumId, a: QueryLeafBy.AlbumId,
b: albumId, b: albumId,
@ -106,11 +106,11 @@ export function AlbumWindowControlled(props: {
) )
.catch((e: any) => { handleNotLoggedIn(auth, e) }); .catch((e: any) => { handleNotLoggedIn(auth, e) });
dispatch({ dispatch({
type: AlbumWindowStateActions.SetSongs, type: AlbumWindowStateActions.SetTracks,
value: songs, value: tracks,
}); });
})(); })();
}, [songsOnAlbum, albumId, dispatch]); }, [tracksOnAlbum, albumId, dispatch]);
const [editingName, setEditingName] = useState<string | null>(null); const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText const name = <Typography variant="h4"><EditableText
@ -148,7 +148,7 @@ export function AlbumWindowControlled(props: {
<Box> <Box>
<SubmitChangesButton onClick={() => { <SubmitChangesButton onClick={() => {
setApplying(true); setApplying(true);
modifyAlbum(props.state.id, pendingChanges || {}) modifyAlbum(props.state.id, pendingChanges || { mbApi_typename: 'album' })
.then(() => { .then(() => {
setApplying(false); setApplying(false);
props.dispatch({ props.dispatch({
@ -194,13 +194,13 @@ export function AlbumWindowControlled(props: {
width="80%" width="80%"
> >
<Box display="flex" flexDirection="column" alignItems="left"> <Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs in this album in your library:</Typography> <Typography>Tracks in this album in your library:</Typography>
</Box> </Box>
{props.state.songsOnAlbum && <SongTable {props.state.tracksOnAlbum && <TrackTable
songs={props.state.songsOnAlbum} tracks={props.state.tracksOnAlbum}
songGetters={props.state.songGetters} trackGetters={props.state.trackGetters}
/>} />}
{!props.state.songsOnAlbum && <CircularProgress />} {!props.state.tracksOnAlbum && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -1,35 +1,35 @@
import React, { useEffect, useState, useReducer } from 'react'; import React, { useEffect, useState, useReducer } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../../api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable'; import TrackTable, { TrackGetters } from '../../tables/ResultsTable';
import { modifyArtist } from '../../../lib/saveChanges'; import { modifyArtist } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, querySongs } from '../../../lib/backend/queries'; import { queryArtists, queryTracks } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters'; import { trackGetters } from '../../../lib/trackGetters';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
export type ArtistMetadata = serverApi.ArtistDetails; export type ArtistMetadata = serverApi.ArtistWithId;
export type ArtistMetadataChanges = serverApi.ModifyArtistRequest; export type ArtistMetadataChanges = serverApi.PatchArtistRequest;
export interface ArtistWindowState extends WindowState { export interface ArtistWindowState extends WindowState {
id: number, id: number,
metadata: ArtistMetadata | null, metadata: ArtistMetadata | null,
pendingChanges: ArtistMetadataChanges | null, pendingChanges: ArtistMetadataChanges | null,
songsByArtist: any[] | null, tracksByArtist: any[] | null,
songGetters: SongGetters, trackGetters: TrackGetters,
} }
export enum ArtistWindowStateActions { export enum ArtistWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges", SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs", SetTracks = "SetTracks",
Reload = "Reload", Reload = "Reload",
} }
@ -39,10 +39,10 @@ export function ArtistWindowReducer(state: ArtistWindowState, action: any) {
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case ArtistWindowStateActions.SetPendingChanges: case ArtistWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value } return { ...state, pendingChanges: action.value }
case ArtistWindowStateActions.SetSongs: case ArtistWindowStateActions.SetTracks:
return { ...state, songsByArtist: action.value } return { ...state, tracksByArtist: action.value }
case ArtistWindowStateActions.Reload: case ArtistWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsByArtist: null } return { ...state, metadata: null, pendingChanges: null, tracksByArtist: null }
default: default:
throw new Error("Unimplemented ArtistWindow state update.") throw new Error("Unimplemented ArtistWindow state update.")
} }
@ -70,8 +70,8 @@ export default function ArtistWindow(props: {}) {
id: parseInt(id), id: parseInt(id),
metadata: null, metadata: null,
pendingChanges: null, pendingChanges: null,
songGetters: songGetters, trackGetters: trackGetters,
songsByArtist: null, tracksByArtist: null,
}); });
return <ArtistWindowControlled state={state} dispatch={dispatch} /> return <ArtistWindowControlled state={state} dispatch={dispatch} />
@ -81,7 +81,7 @@ export function ArtistWindowControlled(props: {
state: ArtistWindowState, state: ArtistWindowState,
dispatch: (action: any) => void, dispatch: (action: any) => void,
}) { }) {
let { metadata, id: artistId, pendingChanges, songsByArtist } = props.state; let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state;
let { dispatch } = props; let { dispatch } = props;
let auth = useAuth(); let auth = useAuth();
@ -97,12 +97,12 @@ export function ArtistWindowControlled(props: {
.catch((e: any) => { handleNotLoggedIn(auth, e) }) .catch((e: any) => { handleNotLoggedIn(auth, e) })
}, [artistId, dispatch]); }, [artistId, dispatch]);
// Effect to get the artist's songs. // Effect to get the artist's tracks.
useEffect(() => { useEffect(() => {
if (songsByArtist) { return; } if (tracksByArtist) { return; }
(async () => { (async () => {
const songs = await querySongs( const tracks = await queryTracks(
{ {
a: QueryLeafBy.ArtistId, a: QueryLeafBy.ArtistId,
b: artistId, b: artistId,
@ -111,11 +111,11 @@ export function ArtistWindowControlled(props: {
) )
.catch((e: any) => { handleNotLoggedIn(auth, e) }); .catch((e: any) => { handleNotLoggedIn(auth, e) });
dispatch({ dispatch({
type: ArtistWindowStateActions.SetSongs, type: ArtistWindowStateActions.SetTracks,
value: songs, value: tracks,
}); });
})(); })();
}, [songsByArtist, dispatch, artistId]); }, [tracksByArtist, dispatch, artistId]);
const [editingName, setEditingName] = useState<string | null>(null); const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText const name = <Typography variant="h4"><EditableText
@ -153,7 +153,7 @@ export function ArtistWindowControlled(props: {
<Box> <Box>
<SubmitChangesButton onClick={() => { <SubmitChangesButton onClick={() => {
setApplying(true); setApplying(true);
modifyArtist(props.state.id, pendingChanges || {}) modifyArtist(props.state.id, pendingChanges || { mbApi_typename: 'artist' })
.then(() => { .then(() => {
setApplying(false); setApplying(false);
props.dispatch({ props.dispatch({
@ -199,13 +199,13 @@ export function ArtistWindowControlled(props: {
width="80%" width="80%"
> >
<Box display="flex" flexDirection="column" alignItems="left"> <Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs by this artist in your library:</Typography> <Typography>Tracks by this artist in your library:</Typography>
</Box> </Box>
{props.state.songsByArtist && <SongTable {props.state.tracksByArtist && <TrackTable
songs={props.state.songsByArtist} tracks={props.state.tracksByArtist}
songGetters={props.state.songGetters} trackGetters={props.state.trackGetters}
/>} />}
{!props.state.songsByArtist && <CircularProgress />} {!props.state.tracksByArtist && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -3,15 +3,15 @@ import { Box, Button, Checkbox, createStyles, Dialog, DialogActions, DialogConte
import StoreLinkIcon from '../../common/StoreLinkIcon'; import StoreLinkIcon from '../../common/StoreLinkIcon';
import { $enum } from 'ts-enum-util'; import { $enum } from 'ts-enum-util';
import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations'; 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 { start } from 'repl';
import { QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot } from '../../../lib/query/Query'; 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 asyncPool from "tiny-async-pool";
import { getSong } from '../../../lib/backend/songs'; import { getTrack } from '../../../lib/backend/tracks';
import { getAlbum } from '../../../lib/backend/albums'; import { getAlbum } from '../../../lib/backend/albums';
import { getArtist } from '../../../lib/backend/artists'; 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) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@ -29,10 +29,10 @@ enum BatchJobState {
} }
interface Task { interface Task {
itemType: ItemType, itemType: ResourceType,
itemId: number, itemId: number,
integrationId: number, integrationId: number,
store: ExternalStore, store: IntegrationWith,
} }
interface BatchJobStatus { interface BatchJobStatus {
@ -44,33 +44,33 @@ interface BatchJobStatus {
async function makeTasks( async function makeTasks(
integration: IntegrationState, integration: IntegrationState,
linkSongs: boolean, linkTracks: boolean,
linkArtists: boolean, linkArtists: boolean,
linkAlbums: boolean, linkAlbums: boolean,
addTaskCb: (t: Task) => void, addTaskCb: (t: Task) => void,
) { ) {
let whichProp: any = { let whichProp: any = {
[ItemType.Song]: QueryLeafBy.SongStoreLinks, [ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ItemType.Artist]: QueryLeafBy.ArtistStoreLinks, [ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ItemType.Album]: QueryLeafBy.AlbumStoreLinks, [ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
} }
let whichElem: any = { let whichElem: any = {
[ItemType.Song]: 'songs', [ResourceType.Track]: 'tracks',
[ItemType.Artist]: 'artists', [ResourceType.Artist]: 'artists',
[ItemType.Album]: 'albums', [ResourceType.Album]: 'albums',
} }
let maybeStore = integration.integration.providesStoreLink(); let maybeStore = integration.integration.providesStoreLink();
if (!maybeStore) { if (!maybeStore) {
return; return;
} }
let store = maybeStore as ExternalStore; let store = maybeStore as IntegrationWith;
let doForType = async (type: ItemType) => { let doForType = async (type: ResourceType) => {
let ids: number[] = ((await queryItems( let ids: number[] = ((await queryItems(
[type], [type],
queryNot({ queryNot({
a: whichProp[type], a: whichProp[type],
leafOp: QueryLeafOp.Like, leafOp: QueryLeafOp.Like,
b: `%${StoreURLIdentifiers[store]}%`, b: `%${IntegrationUrls[store]}%`,
}), }),
undefined, undefined,
undefined, undefined,
@ -86,15 +86,15 @@ async function makeTasks(
}) })
} }
var promises: Promise<any>[] = []; var promises: Promise<any>[] = [];
if (linkSongs) { promises.push(doForType(ItemType.Song)); } if (linkTracks) { promises.push(doForType(ResourceType.Track)); }
if (linkArtists) { promises.push(doForType(ItemType.Artist)); } if (linkArtists) { promises.push(doForType(ResourceType.Artist)); }
if (linkAlbums) { promises.push(doForType(ItemType.Album)); } if (linkAlbums) { promises.push(doForType(ResourceType.Album)); }
console.log("Awaiting answer...") console.log("Awaiting answer...")
await Promise.all(promises); await Promise.all(promises);
} }
async function doLinking( async function doLinking(
toLink: { integrationId: number, songs: boolean, artists: boolean, albums: boolean }[], toLink: { integrationId: number, tracks: boolean, artists: boolean, albums: boolean }[],
setStatus: any, setStatus: any,
integrations: IntegrationState[], integrations: IntegrationState[],
) { ) {
@ -114,13 +114,13 @@ async function doLinking(
var tasks: Task[] = []; var tasks: Task[] = [];
let collectionPromises = toLink.map((v: any) => { 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); let integration = integrations.find((i: IntegrationState) => i.id === integrationId);
if (!integration) { return; } if (!integration) { return; }
console.log('integration collect:', integration) console.log('integration collect:', integration)
return makeTasks( return makeTasks(
integration, integration,
songs, tracks,
artists, artists,
albums, albums,
(t: Task) => { tasks.push(t) } (t: Task) => { tasks.push(t) }
@ -160,28 +160,28 @@ async function doLinking(
console.log('integration search:', integration) console.log('integration search:', integration)
let _integration = integration as IntegrationState; let _integration = integration as IntegrationState;
let searchFuncs: any = { let searchFuncs: any = {
[ItemType.Song]: (q: any, l: any) => { return _integration.integration.searchSong(q, l) }, [ResourceType.Track]: (q: any, l: any) => { return _integration.integration.searchTrack(q, l) },
[ItemType.Album]: (q: any, l: any) => { return _integration.integration.searchAlbum(q, l) }, [ResourceType.Album]: (q: any, l: any) => { return _integration.integration.searchAlbum(q, l) },
[ItemType.Artist]: (q: any, l: any) => { return _integration.integration.searchArtist(q, l) }, [ResourceType.Artist]: (q: any, l: any) => { return _integration.integration.searchArtist(q, l) },
} }
// TODO include related items in search // TODO include related items in search
let getFuncs: any = { let getFuncs: any = {
[ItemType.Song]: getSong, [ResourceType.Track]: getTrack,
[ItemType.Album]: getAlbum, [ResourceType.Album]: getAlbum,
[ItemType.Artist]: getArtist, [ResourceType.Artist]: getArtist,
} }
let queryFuncs: any = { 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.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}` +
`${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`, `${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`,
[ItemType.Album]: (s: any) => `${s.name}` + [ResourceType.Album]: (s: any) => `${s.name}` +
`${s.artists && s.artists.length > 0 && ` ${s.artists[0].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 = { let modifyFuncs: any = {
[ItemType.Song]: modifySong, [ResourceType.Track]: modifyTrack,
[ItemType.Album]: modifyAlbum, [ResourceType.Album]: modifyAlbum,
[ItemType.Artist]: modifyArtist, [ResourceType.Artist]: modifyArtist,
} }
let item = await getFuncs[t.itemType](t.itemId); let item = await getFuncs[t.itemType](t.itemId);
let query = queryFuncs[t.itemType](item); let query = queryFuncs[t.itemType](item);
@ -295,32 +295,32 @@ export default function BatchLinkDialog(props: {
tasksFailed: 0, tasksFailed: 0,
}); });
var compatibleIntegrations: Record<ExternalStore, IntegrationState[]> = { var compatibleIntegrations: Record<IntegrationWith, IntegrationState[]> = {
[ExternalStore.GooglePlayMusic]: [], [IntegrationWith.GooglePlayMusic]: [],
[ExternalStore.YoutubeMusic]: [], [IntegrationWith.YoutubeMusic]: [],
[ExternalStore.Spotify]: [], [IntegrationWith.Spotify]: [],
}; };
$enum(ExternalStore).getValues().forEach((store: ExternalStore) => { $enum(IntegrationWith).getValues().forEach((store: IntegrationWith) => {
compatibleIntegrations[store] = Array.isArray(integrations.state) ? 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 { interface StoreSettings {
selectedIntegration: number | undefined, // Index into compatibleIntegrations selectedIntegration: number | undefined, // Index into compatibleIntegrations
linkArtists: boolean, linkArtists: boolean,
linkSongs: boolean, linkTracks: boolean,
linkAlbums: boolean, linkAlbums: boolean,
} }
let [storeSettings, setStoreSettings] = useState<Record<ExternalStore, StoreSettings>>( let [storeSettings, setStoreSettings] = useState<Record<IntegrationWith, StoreSettings>>(
$enum(ExternalStore).getValues().reduce((prev: any, cur: ExternalStore) => { $enum(IntegrationWith).getValues().reduce((prev: any, cur: IntegrationWith) => {
return { return {
...prev, ...prev,
[cur]: { [cur]: {
selectedIntegration: compatibleIntegrations[cur].length > 0 ? 0 : undefined, selectedIntegration: compatibleIntegrations[cur].length > 0 ? 0 : undefined,
linkArtists: false, linkArtists: false,
linkSongs: false, linkTracks: false,
linkAlbums: false, linkAlbums: false,
} }
} }
@ -352,7 +352,7 @@ export default function BatchLinkDialog(props: {
<th><Typography><b>Use Integration</b></Typography></th> <th><Typography><b>Use Integration</b></Typography></th>
<td><Typography><b>Which items</b></Typography></td> <td><Typography><b>Which items</b></Typography></td>
</tr> </tr>
{$enum(ExternalStore).getValues().map((store: ExternalStore) => { {$enum(IntegrationWith).getValues().map((store: IntegrationWith) => {
let active = Boolean(compatibleIntegrations[store].length); let active = Boolean(compatibleIntegrations[store].length);
return <tr> return <tr>
@ -391,9 +391,9 @@ export default function BatchLinkDialog(props: {
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkAlbums: e.target.checked } } })} /> onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkAlbums: e.target.checked } } })} />
} label={<Text enabled={active}>Albums</Text>} /> } label={<Text enabled={active}>Albums</Text>} />
<FormControlLabel control={ <FormControlLabel control={
<Checkbox disabled={!active} checked={storeSettings[store].linkSongs} <Checkbox disabled={!active} checked={storeSettings[store].linkTracks}
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkSongs: e.target.checked } } })} /> onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkTracks: e.target.checked } } })} />
} label={<Text enabled={active}>Songs</Text>} /> } label={<Text enabled={active}>Tracks</Text>} />
</td> </td>
</tr>; </tr>;
})} })}
@ -412,13 +412,13 @@ export default function BatchLinkDialog(props: {
onConfirm={() => { onConfirm={() => {
var toLink: any[] = []; var toLink: any[] = [];
Object.keys(storeSettings).forEach((store: string) => { Object.keys(storeSettings).forEach((store: string) => {
let s = store as ExternalStore; let s = store as IntegrationWith;
let active = Boolean(compatibleIntegrations[s].length); let active = Boolean(compatibleIntegrations[s].length);
if (active && storeSettings[s].selectedIntegration !== undefined) { if (active && storeSettings[s].selectedIntegration !== undefined) {
toLink.push({ toLink.push({
integrationId: compatibleIntegrations[s][storeSettings[s].selectedIntegration || 0].id, integrationId: compatibleIntegrations[s][storeSettings[s].selectedIntegration || 0].id,
songs: storeSettings[s].linkSongs, tracks: storeSettings[s].linkTracks,
artists: storeSettings[s].linkArtists, artists: storeSettings[s].linkArtists,
albums: storeSettings[s].linkAlbums, albums: storeSettings[s].linkAlbums,
}); });

@ -1,7 +1,7 @@
import { Box, LinearProgress, Typography } from '@material-ui/core'; import { Box, LinearProgress, Typography } from '@material-ui/core';
import React, { useCallback, useEffect, useReducer, useState } from 'react'; import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { $enum } from 'ts-enum-util'; 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 { queryItems } from '../../../lib/backend/queries';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import StoreLinkIcon from '../../common/StoreLinkIcon'; import StoreLinkIcon from '../../common/StoreLinkIcon';
@ -20,23 +20,23 @@ export default function LinksStatusWidget(props: {
let [totalCounts, setTotalCounts] = useState<Counts | undefined>(undefined); let [totalCounts, setTotalCounts] = useState<Counts | undefined>(undefined);
let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({}); let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({});
let queryStoreCount = async (store: ExternalStore, type: ItemType) => { let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => {
let whichProp: any = { let whichProp: any = {
[ItemType.Song]: QueryLeafBy.SongStoreLinks, [ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ItemType.Artist]: QueryLeafBy.ArtistStoreLinks, [ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ItemType.Album]: QueryLeafBy.AlbumStoreLinks, [ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
} }
let whichElem: any = { let whichElem: any = {
[ItemType.Song]: 'songs', [ResourceType.Track]: 'songs',
[ItemType.Artist]: 'artists', [ResourceType.Artist]: 'artists',
[ItemType.Album]: 'albums', [ResourceType.Album]: 'albums',
} }
let r: any = await queryItems( let r: any = await queryItems(
[type], [type],
{ {
a: whichProp[type], a: whichProp[type],
leafOp: QueryLeafOp.Like, leafOp: QueryLeafOp.Like,
b: `%${StoreURLIdentifiers[store]}%`, b: `%${IntegrationUrls[store]}%`,
}, },
undefined, undefined,
undefined, undefined,
@ -49,7 +49,7 @@ export default function LinksStatusWidget(props: {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let counts: any = await queryItems( let counts: any = await queryItems(
[ItemType.Song, ItemType.Artist, ItemType.Album], [ResourceType.Track, ResourceType.Artist, ResourceType.Album],
undefined, undefined,
undefined, undefined,
undefined, undefined,
@ -63,10 +63,10 @@ export default function LinksStatusWidget(props: {
// Start retrieving counts per store // Start retrieving counts per store
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let promises = $enum(ExternalStore).getValues().map((s: ExternalStore) => { let promises = $enum(IntegrationWith).getValues().map((s: IntegrationWith) => {
let songsPromise: Promise<number> = queryStoreCount(s, ItemType.Song); let songsPromise: Promise<number> = queryStoreCount(s, ResourceType.Track);
let albumsPromise: Promise<number> = queryStoreCount(s, ItemType.Album); let albumsPromise: Promise<number> = queryStoreCount(s, ResourceType.Album);
let artistsPromise: Promise<number> = queryStoreCount(s, ItemType.Artist); let artistsPromise: Promise<number> = queryStoreCount(s, ResourceType.Artist);
let updatePromise = Promise.all([songsPromise, albumsPromise, artistsPromise]).then( let updatePromise = Promise.all([songsPromise, albumsPromise, artistsPromise]).then(
(r: any[]) => { (r: any[]) => {
setLinkedCounts((prev: Record<string, Counts>) => { setLinkedCounts((prev: Record<string, Counts>) => {
@ -89,13 +89,13 @@ export default function LinksStatusWidget(props: {
)(); )();
}, [setLinkedCounts]); }, [setLinkedCounts]);
let storeReady = (s: ExternalStore) => { let storeReady = (s: IntegrationWith) => {
return s in linkedCounts; return s in linkedCounts;
} }
return <Box display="flex" flexDirection="column" alignItems="left"> return <Box display="flex" flexDirection="column" alignItems="left">
<table> <table>
{$enum(ExternalStore).getValues().map((s: ExternalStore) => { {$enum(IntegrationWith).getValues().map((s: IntegrationWith) => {
if (!totalCounts) { return <></>; } if (!totalCounts) { return <></>; }
if (!storeReady(s)) { return <></>; } if (!storeReady(s)) { return <></>; }
let tot = totalCounts; let tot = totalCounts;

@ -13,7 +13,7 @@ import Alert from '@material-ui/lab/Alert';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request'; import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
import * as serverApi from '../../../api'; import * as serverApi from '../../../api/api';
var _ = require('lodash'); var _ = require('lodash');
export interface ManageTagsWindowState extends WindowState { export interface ManageTagsWindowState extends WindowState {

@ -36,13 +36,14 @@ export async function submitTagChanges(changes: TagChange[]) {
for (const change of changes) { for (const change of changes) {
// If string is of form "1", convert to ID number directly. // If string is of form "1", convert to ID number directly.
// Otherwise, look it up in the table. // 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 numericId = change.id ? getId(change.id) : undefined;
const intoId = change.into ? getId(change.into) : undefined; const intoId = change.into ? getId(change.into) : undefined;
switch (change.type) { switch (change.type) {
case TagChangeType.Create: case TagChangeType.Create:
if (!change.name) { throw new Error("Cannot create tag without name"); } if (!change.name) { throw new Error("Cannot create tag without name"); }
const { id } = await createTag({ const { id } = await createTag({
mbApi_typename: 'tag',
name: change.name, name: change.name,
parentId: parentId, parentId: parentId,
}); });
@ -53,6 +54,7 @@ export async function submitTagChanges(changes: TagChange[]) {
await modifyTag( await modifyTag(
numericId, numericId,
{ {
mbApi_typename: 'tag',
parentId: parentId, parentId: parentId,
}) })
break; break;
@ -61,6 +63,7 @@ export async function submitTagChanges(changes: TagChange[]) {
await modifyTag( await modifyTag(
numericId, numericId,
{ {
mbApi_typename: 'tag',
name: change.name, name: change.name,
}) })
break; break;

@ -2,11 +2,11 @@ import React, { useEffect, useReducer, useCallback } from 'react';
import { Box, LinearProgress } from '@material-ui/core'; import { Box, LinearProgress } from '@material-ui/core';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder from '../../querybuilder/QueryBuilder'; import QueryBuilder from '../../querybuilder/QueryBuilder';
import SongTable from '../../tables/ResultsTable'; import TrackTable from '../../tables/ResultsTable';
import { songGetters } from '../../../lib/songGetters'; import { trackGetters } from '../../../lib/trackGetters';
import { queryArtists, querySongs, queryAlbums, queryTags } from '../../../lib/backend/queries'; import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import { QueryResponseType } from '../../../api'; import { QueryResponseType } from '../../../api/api';
var _ = require('lodash'); var _ = require('lodash');
export interface ResultsForQuery { export interface ResultsForQuery {
@ -52,17 +52,17 @@ async function getAlbumNames(filter: string) {
return [...(new Set([...(albums.map((a: any) => a.name))]))]; return [...(new Set([...(albums.map((a: any) => a.name))]))];
} }
async function getSongTitles(filter: string) { async function getTrackNames(filter: string) {
const songs: any = await querySongs( const tracks: any = await queryTracks(
filter.length > 0 ? { filter.length > 0 ? {
a: QueryLeafBy.SongTitle, a: QueryLeafBy.TrackName,
b: '%' + filter + '%', b: '%' + filter + '%',
leafOp: QueryLeafOp.Like leafOp: QueryLeafOp.Like
} : undefined, } : undefined,
0, -1, QueryResponseType.Details 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<any> { async function getTagItems(): Promise<any> {
@ -117,7 +117,7 @@ export function QueryWindowControlled(props: {
const showResults = (query && resultsFor && query === resultsFor.for) ? resultsFor.results : []; const showResults = (query && resultsFor && query === resultsFor.for) ? resultsFor.results : [];
const doQuery = useCallback(async (_query: QueryElem) => { const doQuery = useCallback(async (_query: QueryElem) => {
const songs: any = await querySongs( const tracks: any = await queryTracks(
_query, _query,
0, 0,
100, //TODO: pagination 100, //TODO: pagination
@ -127,7 +127,7 @@ export function QueryWindowControlled(props: {
if (_.isEqual(query, _query)) { if (_.isEqual(query, _query)) {
setResultsForQuery({ setResultsForQuery({
for: _query, for: _query,
results: songs, results: tracks,
}) })
} }
}, [query, setResultsForQuery]); }, [query, setResultsForQuery]);
@ -152,7 +152,7 @@ export function QueryWindowControlled(props: {
onChangeEditing={setEditingQuery} onChangeEditing={setEditingQuery}
requestFunctions={{ requestFunctions={{
getArtists: getArtistNames, getArtists: getArtistNames,
getSongTitles: getSongTitles, getTrackNames: getTrackNames,
getAlbums: getAlbumNames, getAlbums: getAlbumNames,
getTags: getTagItems, getTags: getTagItems,
}} }}
@ -162,9 +162,9 @@ export function QueryWindowControlled(props: {
m={1} m={1}
width="80%" width="80%"
> >
<SongTable <TrackTable
songs={showResults} tracks={showResults}
songGetters={songGetters} trackGetters={trackGetters}
/> />
{loading && <LinearProgress />} {loading && <LinearProgress />}
</Box> </Box>

@ -6,7 +6,7 @@ import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import ClearIcon from '@material-ui/icons/Clear'; 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 { v4 as genUuid } from 'uuid';
import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations'; import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations';
import Alert from '@material-ui/lab/Alert'; import Alert from '@material-ui/lab/Alert';
@ -59,7 +59,7 @@ function EditSpotifyClientCredentialsDetails(props: {
// of an integration. // of an integration.
function EditIntegration(props: { function EditIntegration(props: {
upstreamId?: number, upstreamId?: number,
integration: serverApi.CreateIntegrationRequest, integration: serverApi.PostIntegrationRequest,
editing?: boolean, editing?: boolean,
showSubmitButton?: boolean | "InProgress", showSubmitButton?: boolean | "InProgress",
showDeleteButton?: boolean | "InProgress", showDeleteButton?: boolean | "InProgress",
@ -68,27 +68,27 @@ function EditIntegration(props: {
showCancelButton?: boolean, showCancelButton?: boolean,
flashMessage?: React.ReactFragment, flashMessage?: React.ReactFragment,
isNew: boolean, isNew: boolean,
onChange?: (p: serverApi.CreateIntegrationRequest) => void, onChange?: (p: serverApi.PostIntegrationRequest) => void,
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, onSubmit?: (p: serverApi.PostIntegrationRequest) => void,
onDelete?: () => void, onDelete?: () => void,
onEdit?: () => void, onEdit?: () => void,
onTest?: () => void, onTest?: () => void,
onCancel?: () => void, onCancel?: () => void,
}) { }) {
let IntegrationHeaders: Record<any, any> = { let IntegrationHeaders: Record<any, any> = {
[serverApi.IntegrationType.SpotifyClientCredentials]: [serverApi.IntegrationImpl.SpotifyClientCredentials]:
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box mr={1}> <Box mr={1}>
{new IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials](-1).getIcon({ {new IntegrationClasses[serverApi.IntegrationImpl.SpotifyClientCredentials](-1).getIcon({
style: { height: '40px', width: '40px' } style: { height: '40px', width: '40px' }
})} })}
</Box> </Box>
<Typography>Spotify (using Client Credentials)</Typography> <Typography>Spotify (using Client Credentials)</Typography>
</Box>, </Box>,
[serverApi.IntegrationType.YoutubeWebScraper]: [serverApi.IntegrationImpl.YoutubeWebScraper]:
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box mr={1}> <Box mr={1}>
{new IntegrationClasses[serverApi.IntegrationType.YoutubeWebScraper](-1).getIcon({ {new IntegrationClasses[serverApi.IntegrationImpl.YoutubeWebScraper](-1).getIcon({
style: { height: '40px', width: '40px' } style: { height: '40px', width: '40px' }
})} })}
</Box> </Box>
@ -96,7 +96,7 @@ function EditIntegration(props: {
</Box>, </Box>,
} }
let IntegrationDescription: Record<any, any> = { let IntegrationDescription: Record<any, any> = {
[serverApi.IntegrationType.SpotifyClientCredentials]: [serverApi.IntegrationImpl.SpotifyClientCredentials]:
<Typography> <Typography>
This integration allows using the Spotify API to make requests that are This integration allows using the Spotify API to make requests that are
tied to any specific user, such as searching items and retrieving item 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 and client secret. Once set, you will only be able to overwrite the secret
here, not read it. here, not read it.
</Typography>, </Typography>,
[serverApi.IntegrationType.YoutubeWebScraper]: [serverApi.IntegrationImpl.YoutubeWebScraper]:
<Typography> <Typography>
This integration allows using the public Youtube Music search page to scrape This integration allows using the public Youtube Music search page to scrape
for music metadata. <br /> for music metadata. <br />
@ -137,7 +137,7 @@ function EditIntegration(props: {
})} })}
/> />
</Box> </Box>
{props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && {props.integration.type === serverApi.IntegrationImpl.SpotifyClientCredentials &&
<EditSpotifyClientCredentialsDetails <EditSpotifyClientCredentialsDetails
clientId={'clientId' in props.integration.details && clientId={'clientId' in props.integration.details &&
props.integration.details.clientId || ""} props.integration.details.clientId || ""}
@ -207,7 +207,7 @@ function AddIntegrationMenu(props: {
position: null | number[], position: null | number[],
open: boolean, open: boolean,
onClose?: () => void, onClose?: () => void,
onAdd?: (type: serverApi.IntegrationType) => void, onAdd?: (type: serverApi.IntegrationImpl) => void,
}) { }) {
const pos = props.open && props.position ? const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] } { left: props.position[0], top: props.position[1] }
@ -222,13 +222,13 @@ function AddIntegrationMenu(props: {
> >
<MenuItem <MenuItem
onClick={() => { onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); props.onAdd && props.onAdd(serverApi.IntegrationImpl.SpotifyClientCredentials);
props.onClose && props.onClose(); props.onClose && props.onClose();
}} }}
>Spotify via Client Credentials</MenuItem> >Spotify via Client Credentials</MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationType.YoutubeWebScraper); props.onAdd && props.onAdd(serverApi.IntegrationImpl.YoutubeWebScraper);
props.onClose && props.onClose(); props.onClose && props.onClose();
}} }}
>Youtube Music Web Scraper</MenuItem> >Youtube Music Web Scraper</MenuItem>
@ -240,7 +240,7 @@ function EditIntegrationDialog(props: {
onClose?: () => void, onClose?: () => void,
upstreamId?: number, upstreamId?: number,
integration: IntegrationState, integration: IntegrationState,
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, onSubmit?: (p: serverApi.PostIntegrationRequest) => void,
isNew: boolean, isNew: boolean,
}) { }) {
let [editingIntegration, setEditingIntegration] = let [editingIntegration, setEditingIntegration] =
@ -320,7 +320,7 @@ export default function IntegrationSettings(props: {}) {
position={addMenuPos} position={addMenuPos}
open={addMenuPos !== null} open={addMenuPos !== null}
onClose={onCloseAddMenu} onClose={onCloseAddMenu}
onAdd={(type: serverApi.IntegrationType) => { onAdd={(type: serverApi.IntegrationImpl) => {
let p = makeDefaultIntegrationProperties(type); let p = makeDefaultIntegrationProperties(type);
setEditingState({ setEditingState({
properties: p, properties: p,
@ -334,7 +334,7 @@ export default function IntegrationSettings(props: {}) {
onClose={() => { setEditingState(null); }} onClose={() => { setEditingState(null); }}
integration={editingState} integration={editingState}
isNew={editingState.id === -1} isNew={editingState.id === -1}
onSubmit={(v: serverApi.CreateIntegrationRequest) => { onSubmit={(v: serverApi.PostIntegrationRequest) => {
if (editingState.id >= 0) { if (editingState.id >= 0) {
const id = editingState.id; const id = editingState.id;
setEditingState(null); setEditingState(null);

@ -1,38 +1,38 @@
import React, { useEffect, useState, useReducer } from 'react'; import React, { useEffect, useState, useReducer } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core'; import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../../api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable'; import TrackTable, { TrackGetters } from '../../tables/ResultsTable';
import { modifyTag } from '../../../lib/backend/tags'; 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 { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { songGetters } from '../../../lib/songGetters'; import { trackGetters } from '../../../lib/trackGetters';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
export interface FullTagMetadata extends serverApi.TagDetails { export interface FullTagMetadata extends serverApi.TagWithId {
fullName: string[], fullName: string[],
fullId: number[], fullId: number[],
} }
export type TagMetadata = FullTagMetadata; export type TagMetadata = FullTagMetadata;
export type TagMetadataChanges = serverApi.ModifyTagRequest; export type TagMetadataChanges = serverApi.PatchTagRequest;
export interface TagWindowState extends WindowState { export interface TagWindowState extends WindowState {
id: number, id: number,
metadata: TagMetadata | null, metadata: TagMetadata | null,
pendingChanges: TagMetadataChanges | null, pendingChanges: TagMetadataChanges | null,
songsWithTag: any[] | null, tracksWithTag: any[] | null,
songGetters: SongGetters, trackGetters: TrackGetters,
} }
export enum TagWindowStateActions { export enum TagWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges", SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs", SetTracks = "SetTracks",
Reload = "Reload", Reload = "Reload",
} }
@ -42,10 +42,10 @@ export function TagWindowReducer(state: TagWindowState, action: any) {
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case TagWindowStateActions.SetPendingChanges: case TagWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value } return { ...state, pendingChanges: action.value }
case TagWindowStateActions.SetSongs: case TagWindowStateActions.SetTracks:
return { ...state, songsWithTag: action.value } return { ...state, tracksWithTag: action.value }
case TagWindowStateActions.Reload: case TagWindowStateActions.Reload:
return { ...state, metadata: null, songsWithTag: null } return { ...state, metadata: null, tracksWithTag: null }
default: default:
throw new Error("Unimplemented TagWindow state update.") throw new Error("Unimplemented TagWindow state update.")
} }
@ -81,8 +81,8 @@ export default function TagWindow(props: {}) {
id: parseInt(id), id: parseInt(id),
metadata: null, metadata: null,
pendingChanges: null, pendingChanges: null,
songGetters: songGetters, trackGetters: trackGetters,
songsWithTag: null, tracksWithTag: null,
}); });
return <TagWindowControlled state={state} dispatch={dispatch} /> return <TagWindowControlled state={state} dispatch={dispatch} />
@ -94,7 +94,7 @@ export function TagWindowControlled(props: {
}) { }) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges; let pendingChanges = props.state.pendingChanges;
let { id: tagId, songsWithTag } = props.state; let { id: tagId, tracksWithTag } = props.state;
let dispatch = props.dispatch; let dispatch = props.dispatch;
// Effect to get the tag's metadata. // Effect to get the tag's metadata.
@ -108,12 +108,12 @@ export function TagWindowControlled(props: {
}) })
}, [tagId, dispatch]); }, [tagId, dispatch]);
// Effect to get the tag's songs. // Effect to get the tag's tracks.
useEffect(() => { useEffect(() => {
if (songsWithTag) { return; } if (tracksWithTag) { return; }
(async () => { (async () => {
const songs: any = await querySongs( const tracks: any = await queryTracks(
{ {
a: QueryLeafBy.TagId, a: QueryLeafBy.TagId,
b: tagId, b: tagId,
@ -121,11 +121,11 @@ export function TagWindowControlled(props: {
}, 0, -1, serverApi.QueryResponseType.Details, }, 0, -1, serverApi.QueryResponseType.Details,
); );
dispatch({ dispatch({
type: TagWindowStateActions.SetSongs, type: TagWindowStateActions.SetTracks,
value: songs, value: tracks,
}); });
})(); })();
}, [songsWithTag, tagId, dispatch]); }, [tracksWithTag, tagId, dispatch]);
const [editingName, setEditingName] = useState<string | null>(null); const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText const name = <Typography variant="h4"><EditableText
@ -156,25 +156,12 @@ export function TagWindowControlled(props: {
})} })}
</Box> </Box>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank" rel="noopener noreferrer"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 && const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box> <Box>
<SubmitChangesButton onClick={() => { <SubmitChangesButton onClick={() => {
setApplying(true); setApplying(true);
modifyTag(props.state.id, pendingChanges || {}) modifyTag(props.state.id, pendingChanges || { mbApi_typename: 'tag' })
.then(() => { .then(() => {
setApplying(false); setApplying(false);
props.dispatch({ props.dispatch({
@ -201,11 +188,6 @@ export function TagWindowControlled(props: {
<Box m={2}> <Box m={2}>
{fullName} {fullName}
</Box> </Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>} </Box>}
</Box> </Box>
<Box <Box
@ -219,13 +201,13 @@ export function TagWindowControlled(props: {
width="80%" width="80%"
> >
<Box display="flex" flexDirection="column" alignItems="left"> <Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs with this tag in your library:</Typography> <Typography>Tracks with this tag in your library:</Typography>
</Box> </Box>
{props.state.songsWithTag && <SongTable {props.state.tracksWithTag && <TrackTable
songs={props.state.songsWithTag} tracks={props.state.tracksWithTag}
songGetters={props.state.songGetters} trackGetters={props.state.trackGetters}
/>} />}
{!props.state.songsWithTag && <CircularProgress />} {!props.state.tracksWithTag && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; 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 { 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 StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search'; 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 DeleteIcon from '@material-ui/icons/Delete';
import { $enum } from "ts-enum-util"; import { $enum } from "ts-enum-util";
import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations'; 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 { TabPanel } from '@material-ui/lab';
import { v1 } from 'uuid'; import { v1 } from 'uuid';
import { ExternalStore } from '../../../api'; import { IntegrationWith } from '../../../api/api';
let _ = require('lodash') let _ = require('lodash')
export function ProvideLinksWidget(props: { export function ProvideLinksWidget(props: {
providers: IntegrationState[], providers: IntegrationState[],
metadata: SongMetadata, metadata: TrackMetadata,
store: ExternalStore, store: IntegrationWith,
onChange: (link: string | undefined) => void, 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<number | undefined>( let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined props.providers.length > 0 ? 0 : undefined
); );
let [query, setQuery] = useState<string>(defaultQuery) let [query, setQuery] = useState<string>(defaultQuery)
let [results, setResults] = useState<IntegrationSong[] | undefined>(undefined); let [results, setResults] = useState<IntegrationTrack[] | undefined>(undefined);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ? let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined; props.providers[selectedProviderIdx] : undefined;
@ -63,14 +63,14 @@ export function ProvideLinksWidget(props: {
/> />
<IconButton <IconButton
onClick={() => { onClick={() => {
selectedProvider?.integration.searchSong(query, 10) selectedProvider?.integration.searchTrack(query, 10)
.then((songs: IntegrationSong[]) => setResults(songs)) .then((tracks: IntegrationTrack[]) => setResults(tracks))
}} }}
><SearchIcon /></IconButton> ><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>} {results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl> <FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}> <RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results && results.map((result: IntegrationSong, idx: number) => { {results && results.map((result: IntegrationTrack, idx: number) => {
let pretty = `"${result.title}" let pretty = `"${result.title}"
${result.artist && ` by ${result.artist.name}`} ${result.artist && ` by ${result.artist.name}`}
${result.album && ` (${result.album.name})`}`; ${result.album && ` (${result.album.name})`}`;
@ -92,15 +92,15 @@ export function ProvideLinksWidget(props: {
} }
export function ExternalLinksEditor(props: { export function ExternalLinksEditor(props: {
metadata: SongMetadata, metadata: TrackMetadata,
original: SongMetadata, original: TrackMetadata,
onChange: (v: SongMetadata) => void, onChange: (v: TrackMetadata) => void,
}) { }) {
let [selectedIdx, setSelectedIdx] = useState<number>(0); let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations(); let integrations = useIntegrations();
let getLinksSet = (metadata: SongMetadata) => { let getLinksSet = (metadata: TrackMetadata) => {
return $enum(ExternalStore).getValues().reduce((prev: any, store: string) => { return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => {
var maybeLink: string | null = null; var maybeLink: string | null = null;
metadata.storeLinks && metadata.storeLinks.forEach((link: string) => { metadata.storeLinks && metadata.storeLinks.forEach((link: string) => {
if (whichStore(link) === store) { if (whichStore(link) === store) {
@ -117,11 +117,11 @@ export function ExternalLinksEditor(props: {
let linksSet: Record<string, string | null> = getLinksSet(props.metadata); let linksSet: Record<string, string | null> = getLinksSet(props.metadata);
let originalLinksSet: Record<string, string | null> = getLinksSet(props.original); let originalLinksSet: Record<string, string | null> = getLinksSet(props.original);
let store = $enum(ExternalStore).getValues()[selectedIdx]; let store = $enum(IntegrationWith).getValues()[selectedIdx];
let providers: IntegrationState[] = Array.isArray(integrations.state) ? let providers: IntegrationState[] = Array.isArray(integrations.state) ?
integrations.state.filter( integrations.state.filter(
(iState: IntegrationState) => ( (iState: IntegrationState) => (
iState.integration.getFeatures().includes(IntegrationFeature.SearchSong) && iState.integration.getFeatures().includes(IntegrationFeature.SearchTrack) &&
iState.integration.providesStoreLink() === store iState.integration.providesStoreLink() === store
) )
) : []; ) : [];
@ -130,7 +130,7 @@ export function ExternalLinksEditor(props: {
<Box width="30%"> <Box width="30%">
<List> <List>
{$enum(ExternalStore).getValues().map((store: string, idx: number) => { {$enum(IntegrationWith).getValues().map((store: string, idx: number) => {
let maybeLink = linksSet[store]; let maybeLink = linksSet[store];
let color: string | undefined = let color: string | undefined =
(linksSet[store] && !originalLinksSet[store]) ? "lightgreen" : (linksSet[store] && !originalLinksSet[store]) ? "lightgreen" :
@ -190,19 +190,19 @@ export function ExternalLinksEditor(props: {
</Box > </Box >
} }
export default function EditSongDialog(props: { export default function EditTrackDialog(props: {
open: boolean, open: boolean,
onClose: () => void, onClose: () => void,
onSubmit: (v: SongMetadata) => void, onSubmit: (v: TrackMetadata) => void,
id: number, id: number,
metadata: SongMetadata, metadata: TrackMetadata,
}) { }) {
enum EditSongTabs { enum EditTrackTabs {
Details = 0, Details = 0,
ExternalLinks, ExternalLinks,
} }
let [editingMetadata, setEditingMetadata] = useState<SongMetadata>(props.metadata); let [editingMetadata, setEditingMetadata] = useState<TrackMetadata>(props.metadata);
return <Dialog return <Dialog
maxWidth="lg" maxWidth="lg"
@ -217,7 +217,7 @@ export default function EditSongDialog(props: {
<ExternalLinksEditor <ExternalLinksEditor
metadata={editingMetadata} metadata={editingMetadata}
original={props.metadata} original={props.metadata}
onChange={(v: SongMetadata) => setEditingMetadata(v)} onChange={(v: TrackMetadata) => setEditingMetadata(v)}
/> />
<Divider /> <Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions> {!_.isEqual(editingMetadata, props.metadata) && <DialogActions>

@ -3,45 +3,45 @@ import { Box, Typography, IconButton } from '@material-ui/core';
import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album'; import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../../api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import { ArtistMetadata } from '../artist/ArtistWindow'; import { ArtistMetadata } from '../artist/ArtistWindow';
import { AlbumMetadata } from '../album/AlbumWindow'; import { AlbumMetadata } from '../album/AlbumWindow';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; 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 { useParams } from 'react-router';
import EditSongDialog from './EditSongDialog'; import EditTrackDialog from './EditTrackDialog';
import EditIcon from '@material-ui/icons/Edit'; 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, id: number,
metadata: SongMetadata | null, metadata: TrackMetadata | null,
} }
export enum SongWindowStateActions { export enum TrackWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
Reload = "Reload", Reload = "Reload",
} }
export function SongWindowReducer(state: SongWindowState, action: any) { export function TrackWindowReducer(state: TrackWindowState, action: any) {
switch (action.type) { switch (action.type) {
case SongWindowStateActions.SetMetadata: case TrackWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case SongWindowStateActions.Reload: case TrackWindowStateActions.Reload:
return { ...state, metadata: null } return { ...state, metadata: null }
default: default:
throw new Error("Unimplemented SongWindow state update.") throw new Error("Unimplemented TrackWindow state update.")
} }
} }
export async function getSongMetadata(id: number) { export async function getTrackMetadata(id: number) {
let response: any = await querySongs( let response: any = await queryTracks(
{ {
a: QueryLeafBy.SongId, a: QueryLeafBy.TrackId,
b: id, b: id,
leafOp: QueryLeafOp.Equals, leafOp: QueryLeafOp.Equals,
}, 0, 1, serverApi.QueryResponseType.Details }, 0, 1, serverApi.QueryResponseType.Details
@ -49,37 +49,37 @@ export async function getSongMetadata(id: number) {
return response[0]; return response[0];
} }
export default function SongWindow(props: {}) { export default function TrackWindow(props: {}) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [state, dispatch] = useReducer(SongWindowReducer, { const [state, dispatch] = useReducer(TrackWindowReducer, {
id: parseInt(id), id: parseInt(id),
metadata: null, metadata: null,
}); });
return <SongWindowControlled state={state} dispatch={dispatch} /> return <TrackWindowControlled state={state} dispatch={dispatch} />
} }
export function SongWindowControlled(props: { export function TrackWindowControlled(props: {
state: SongWindowState, state: TrackWindowState,
dispatch: (action: any) => void, dispatch: (action: any) => void,
}) { }) {
let { metadata, id: songId } = props.state; let { metadata, id: trackId } = props.state;
let { dispatch } = props; let { dispatch } = props;
let [editing, setEditing] = useState<boolean>(false); let [editing, setEditing] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (metadata === null) { if (metadata === null) {
getSongMetadata(songId) getTrackMetadata(trackId)
.then((m: SongMetadata) => { .then((m: TrackMetadata) => {
dispatch({ dispatch({
type: SongWindowStateActions.SetMetadata, type: TrackWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
} }
}, [songId, dispatch, metadata]); }, [trackId, dispatch, metadata]);
const title = <Typography variant="h4">{metadata?.title || "(Unknown title)"}</Typography> const title = <Typography variant="h4">{metadata?.name || "(Unknown title)"}</Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => { const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => {
return <Typography> return <Typography>
@ -87,11 +87,9 @@ export function SongWindowControlled(props: {
</Typography> </Typography>
}); });
const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => { const album = metadata?.album && <Typography>
return <Typography> {metadata?.album.name}
{album.name} </Typography>;
</Typography>
});
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link); const store = whichStore(link);
@ -134,7 +132,7 @@ export function SongWindowControlled(props: {
<Box display="flex" alignItems="center" m={0.5}> <Box display="flex" alignItems="center" m={0.5}>
<AlbumIcon /> <AlbumIcon />
<Box m={0.5}> <Box m={0.5}>
{albums} {album}
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -150,16 +148,16 @@ export function SongWindowControlled(props: {
</Box> </Box>
</Box>} </Box>}
</Box> </Box>
{metadata && <EditSongDialog {metadata && <EditTrackDialog
open={editing} open={editing}
onClose={() => { setEditing(false); }} onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.ModifySongRequest) => { onSubmit={(v: serverApi.PatchTrackRequest) => {
modifySong(songId, v) modifyTrack(trackId, v)
.then(() => dispatch({ .then(() => dispatch({
type: SongWindowStateActions.Reload type: TrackWindowStateActions.Reload
})) }))
}} }}
id={songId} id={trackId}
metadata={metadata} metadata={metadata}
/>} />}
</Box> </Box>

@ -1,8 +1,9 @@
import * as serverApi from '../../api'; import * as serverApi from '../../api/api';
import { GetAlbumResponse } from '../../api/api';
import backendRequest from './request'; import backendRequest from './request';
export async function getAlbum(id: number) { export async function getAlbum(id: number): Promise<GetAlbumResponse> {
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.AlbumDetailsEndpoint.replace(':id', `${id}`)) const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.GetAlbumEndpoint.replace(':id', `${id}`))
if (!response.ok) { if (!response.ok) {
throw new Error("Response to album request not OK: " + JSON.stringify(response)); throw new Error("Response to album request not OK: " + JSON.stringify(response));
} }

@ -1,8 +1,9 @@
import * as serverApi from '../../api'; import * as serverApi from '../../api/api';
import { GetArtistResponse } from '../../api/api';
import backendRequest from './request'; import backendRequest from './request';
export async function getArtist(id: number) { export async function getArtist(id: number): Promise<GetArtistResponse> {
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.ArtistDetailsEndpoint.replace(':id', `${id}`)) const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.GetArtistEndpoint.replace(':id', `${id}`))
if (!response.ok) { if (!response.ok) {
throw new Error("Response to artist request not OK: " + JSON.stringify(response)); throw new Error("Response to artist request not OK: " + JSON.stringify(response));
} }

@ -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 { useAuth } from '../useAuth';
import backendRequest from './request'; import backendRequest from './request';
export async function createIntegration(details: serverApi.CreateIntegrationRequest) { export async function createIntegration(details: serverApi.PostIntegrationRequest): Promise<serverApi.PostIntegrationResponse> {
const requestOpts = { const requestOpts = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details), 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) { if (!response.ok) {
throw new Error("Response to integration creation not OK: " + JSON.stringify(response)); 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(); return await response.json();
} }
export async function modifyIntegration(id: number, details: serverApi.ModifyIntegrationRequest) { export async function modifyIntegration(id: number, details: serverApi.PatchIntegrationRequest): Promise<serverApi.PatchIntegrationResponse> {
const requestOpts = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -25,16 +26,16 @@ export async function modifyIntegration(id: number, details: serverApi.ModifyInt
}; };
const response = await backendRequest( 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 requestOpts
); );
if (!response.ok) { 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<serverApi.DeleteIntegrationResponse> {
const requestOpts = { const requestOpts = {
method: 'DELETE', method: 'DELETE',
}; };
@ -48,7 +49,7 @@ export async function deleteIntegration(id: number) {
} }
} }
export async function getIntegrations() { export async function getIntegrations(): Promise<serverApi.ListIntegrationsResponse> {
const requestOpts = { const requestOpts = {
method: 'GET', method: 'GET',
}; };

@ -1,41 +1,26 @@
import * as serverApi from '../../api'; import * as serverApi from '../../api/api';
import { QueryElem, toApiQuery } from '../query/Query'; import { QueryElem, toApiQuery } from '../query/Query';
import backendRequest from './request'; import backendRequest from './request';
export async function queryItems( export async function queryItems(
types: serverApi.ItemType[], types: serverApi.ResourceType[],
query: QueryElem | undefined, query: QueryElem | undefined,
offset: number | undefined, offset: number | undefined,
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<{ ): Promise<serverApi.QueryResponse> {
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,
}> {
console.log("Types:", types); console.log("Types:", types);
var q: serverApi.QueryRequest = { var q: serverApi.QueryRequest = {
query: query ? toApiQuery(query) : {}, query: query ? toApiQuery(query) : {},
offsetsLimits: { offsetsLimits: {
artistOffset: (types.includes(serverApi.ItemType.Artist)) ? (offset || 0) : undefined, artistOffset: (types.includes(serverApi.ResourceType.Artist)) ? (offset || 0) : undefined,
artistLimit: (types.includes(serverApi.ItemType.Artist)) ? (limit || -1) : undefined, artistLimit: (types.includes(serverApi.ResourceType.Artist)) ? (limit || -1) : undefined,
albumOffset: (types.includes(serverApi.ItemType.Album)) ? (offset || 0) : undefined, albumOffset: (types.includes(serverApi.ResourceType.Album)) ? (offset || 0) : undefined,
albumLimit: (types.includes(serverApi.ItemType.Album)) ? (limit || -1) : undefined, albumLimit: (types.includes(serverApi.ResourceType.Album)) ? (limit || -1) : undefined,
songOffset: (types.includes(serverApi.ItemType.Song)) ? (offset || 0) : undefined, trackOffset: (types.includes(serverApi.ResourceType.Track)) ? (offset || 0) : undefined,
songLimit: (types.includes(serverApi.ItemType.Song)) ? (limit || -1) : undefined, trackLimit: (types.includes(serverApi.ResourceType.Track)) ? (limit || -1) : undefined,
tagOffset: (types.includes(serverApi.ItemType.Tag)) ? (offset || 0) : undefined, tagOffset: (types.includes(serverApi.ResourceType.Tag)) ? (offset || 0) : undefined,
tagLimit: (types.includes(serverApi.ItemType.Tag)) ? (limit || -1) : undefined, tagLimit: (types.includes(serverApi.ResourceType.Tag)) ? (limit || -1) : undefined,
}, },
ordering: { ordering: {
orderBy: { orderBy: {
@ -66,36 +51,9 @@ export async function queryArtists(
offset: number | undefined, offset: number | undefined,
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.ArtistDetails[] | number[] | number> { ): Promise<serverApi.ArtistWithId[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Artist], query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Artist], query, offset, limit, responseType);
return r.artists; 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( export async function queryAlbums(
@ -103,73 +61,19 @@ export async function queryAlbums(
offset: number | undefined, offset: number | undefined,
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.AlbumDetails[] | number[] | number> { ): Promise<serverApi.AlbumWithId[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Album], query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Album], query, offset, limit, responseType);
return r.albums; 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, query: QueryElem | undefined,
offset: number | undefined, offset: number | undefined,
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.SongDetails[] | number[] | number> { ): Promise<serverApi.TrackWithId[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Song], query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Track], query, offset, limit, responseType);
return r.songs; return r.tracks;
// 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;
// })();
} }
export async function queryTags( export async function queryTags(
@ -177,54 +81,7 @@ export async function queryTags(
offset: number | undefined, offset: number | undefined,
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.TagDetails[] | number[] | number> { ): Promise<serverApi.TagWithId[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Tag], query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Tag], query, offset, limit, responseType);
return r.tags; 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<number, any> = {};
// 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);
// })();
} }

@ -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();
}

@ -1,21 +1,21 @@
import * as serverApi from '../../api'; import * as serverApi from '../../api/api';
import backendRequest from './request'; import backendRequest from './request';
export async function createTag(details: serverApi.CreateTagRequest) { export async function createTag(details: serverApi.PostTagRequest) {
const requestOpts = { const requestOpts = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details), 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) { if (!response.ok) {
throw new Error("Response to tag creation not OK: " + JSON.stringify(response)); throw new Error("Response to tag creation not OK: " + JSON.stringify(response));
} }
return await response.json(); return await response.json();
} }
export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) { export async function modifyTag(id: number, details: serverApi.PatchTagRequest) {
const requestOpts = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -23,7 +23,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest)
}; };
const response = await backendRequest( 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 requestOpts
); );
if (!response.ok) { if (!response.ok) {

@ -0,0 +1,10 @@
import * as serverApi from '../../api/api';
import backendRequest from './request';
export async function getTrack(id: number): Promise<serverApi.GetTrackResponse> {
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();
}

@ -1,5 +1,5 @@
import React, { ReactFragment } from 'react'; import React, { ReactFragment } from 'react';
import { ExternalStore } from '../../api'; import { IntegrationWith } from '../../api/api';
export interface IntegrationAlbum { export interface IntegrationAlbum {
name?: string, name?: string,
@ -12,7 +12,7 @@ export interface IntegrationArtist {
url?: string, // An URL to access the item externally. url?: string, // An URL to access the item externally.
} }
export interface IntegrationSong { export interface IntegrationTrack {
title?: string, title?: string,
album?: IntegrationAlbum, album?: IntegrationAlbum,
artist?: IntegrationArtist, artist?: IntegrationArtist,
@ -24,10 +24,10 @@ export enum IntegrationFeature {
Test = 0, Test = 0,
// Used to get a bucket of songs (typically: the whole library) // Used to get a bucket of songs (typically: the whole library)
GetSongs, GetTracks,
// Used to search items and get some amount of candidate results. // Used to search items and get some amount of candidate results.
SearchSong, SearchTrack,
SearchAlbum, SearchAlbum,
SearchArtist, SearchArtist,
} }
@ -42,16 +42,16 @@ export default class Integration {
// Common // Common
getFeatures(): IntegrationFeature[] { return []; } getFeatures(): IntegrationFeature[] { return []; }
getIcon(props: any): ReactFragment { return <></> } getIcon(props: any): ReactFragment { return <></> }
providesStoreLink(): ExternalStore | null { return null; } providesStoreLink(): IntegrationWith | null { return null; }
// Requires feature: Test // Requires feature: Test
async test(testParams: any): Promise<void> {} async test(testParams: any): Promise<void> {}
// Requires feature: GetSongs // Requires feature: GetTracks
async getSongs(getSongsParams: any): Promise<IntegrationSong[]> { return []; } async getTracks(getTracksParams: any): Promise<IntegrationTrack[]> { return []; }
// Requires feature: SearchSongs // Requires feature: SearchTracks
async searchSong(query: string, limit: number): Promise<IntegrationSong[]> { return []; } async searchTrack(query: string, limit: number): Promise<IntegrationTrack[]> { return []; }
// Requires feature: SearchAlbum // Requires feature: SearchAlbum
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> { return []; } async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> { return []; }

@ -1,10 +1,10 @@
import React from 'react'; 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 StoreLinkIcon from '../../../components/common/StoreLinkIcon';
import { ExternalStore } from '../../../api'; import { IntegrationWith } from '../../../api/api';
enum SearchType { enum SearchType {
Song = 'track', Track = 'track',
Artist = 'artist', Artist = 'artist',
Album = 'album', Album = 'album',
}; };
@ -20,18 +20,18 @@ export default class SpotifyClientCreds extends Integration {
getFeatures(): IntegrationFeature[] { getFeatures(): IntegrationFeature[] {
return [ return [
IntegrationFeature.Test, IntegrationFeature.Test,
IntegrationFeature.SearchSong, IntegrationFeature.SearchTrack,
IntegrationFeature.SearchAlbum, IntegrationFeature.SearchAlbum,
IntegrationFeature.SearchArtist, IntegrationFeature.SearchArtist,
] ]
} }
getIcon(props: any) { getIcon(props: any) {
return <StoreLinkIcon whichStore={ExternalStore.Spotify} {...props} /> return <StoreLinkIcon whichStore={IntegrationWith.Spotify} {...props} />
} }
providesStoreLink() { providesStoreLink() {
return ExternalStore.Spotify; return IntegrationWith.Spotify;
} }
async test(testParams: {}) { async test(testParams: {}) {
@ -44,8 +44,8 @@ export default class SpotifyClientCreds extends Integration {
} }
} }
async searchSong(query: string, limit: number): Promise<IntegrationSong[]> { async searchTrack(query: string, limit: number): Promise<IntegrationTrack[]> {
return this.search(query, SearchType.Song, limit); return this.search(query, SearchType.Track, limit);
} }
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> { async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> {
return this.search(query, SearchType.Album, limit); 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): async search(query: string, type: SearchType, limit: number):
Promise<IntegrationSong[] | IntegrationAlbum[] | IntegrationArtist[]> { Promise<IntegrationTrack[] | IntegrationAlbum[] | IntegrationArtist[]> {
const response = await fetch( const response = await fetch(
(process.env.REACT_APP_BACKEND || "") + (process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}`); `/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); console.log("Response:", json);
switch(type) { switch(type) {
case SearchType.Song: { case SearchType.Track: {
return json.tracks.items.map((r: any): IntegrationSong => { return json.tracks.items.map((r: any): IntegrationTrack => {
return { return {
title: r.name, title: r.name,
url: r.external_urls.spotify, url: r.external_urls.spotify,

@ -1,6 +1,6 @@
import React, { useState, useContext, createContext, useReducer, useEffect } from "react"; import React, { useState, useContext, createContext, useReducer, useEffect } from "react";
import Integration from "./Integration"; import Integration from "./Integration";
import * as serverApi from '../../api'; import * as serverApi from '../../api/api';
import SpotifyClientCreds from "./spotify/SpotifyClientCreds"; import SpotifyClientCreds from "./spotify/SpotifyClientCreds";
import * as backend from "../backend/integrations"; import * as backend from "../backend/integrations";
import { handleNotLoggedIn, NotLoggedInError } from "../backend/request"; import { handleNotLoggedIn, NotLoggedInError } from "../backend/request";
@ -10,7 +10,7 @@ import YoutubeMusicWebScraper from "./youtubemusic/YoutubeMusicWebScraper";
export type IntegrationState = { export type IntegrationState = {
id: number, id: number,
integration: Integration, integration: Integration,
properties: serverApi.CreateIntegrationRequest, properties: serverApi.PostIntegrationRequest,
}; };
export type IntegrationsState = IntegrationState[] | "Loading"; export type IntegrationsState = IntegrationState[] | "Loading";
@ -20,30 +20,32 @@ export function isIntegrationState(v: any): v is IntegrationState {
export interface Integrations { export interface Integrations {
state: IntegrationsState, state: IntegrationsState,
addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise<number>, addIntegration: (v: serverApi.PostIntegrationRequest) => Promise<number>,
deleteIntegration: (id: number) => Promise<void>, deleteIntegration: (id: number) => Promise<void>,
modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise<void>, modifyIntegration: (id: number, v: serverApi.PostIntegrationRequest) => Promise<void>,
updateFromUpstream: () => Promise<void>, updateFromUpstream: () => Promise<void>,
}; };
export const IntegrationClasses: Record<any, any> = { export const IntegrationClasses: Record<any, any> = {
[serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds, [serverApi.IntegrationImpl.SpotifyClientCredentials]: SpotifyClientCreds,
[serverApi.IntegrationType.YoutubeWebScraper]: YoutubeMusicWebScraper, [serverApi.IntegrationImpl.YoutubeWebScraper]: YoutubeMusicWebScraper,
} }
export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType): export function makeDefaultIntegrationProperties(type: serverApi.IntegrationImpl):
serverApi.CreateIntegrationRequest { serverApi.PostIntegrationRequest {
switch (type) { switch (type) {
case serverApi.IntegrationType.SpotifyClientCredentials: { case serverApi.IntegrationImpl.SpotifyClientCredentials: {
return { return {
mbApi_typename: 'integrationData',
name: "Spotify App", name: "Spotify App",
type: type, type: type,
details: { clientId: "" }, details: { clientId: "" },
secretDetails: { clientSecret: "" }, secretDetails: { clientSecret: "" },
} }
} }
case serverApi.IntegrationType.YoutubeWebScraper: { case serverApi.IntegrationImpl.YoutubeWebScraper: {
return { return {
mbApi_typename: 'integrationData',
name: "Youtube Music Web Scraper", name: "Youtube Music Web Scraper",
type: type, type: type,
details: {}, 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) { switch (p.type) {
case serverApi.IntegrationType.SpotifyClientCredentials: { case serverApi.IntegrationImpl.SpotifyClientCredentials: {
return new SpotifyClientCreds(id); return new SpotifyClientCreds(id);
} }
case serverApi.IntegrationType.YoutubeWebScraper: { case serverApi.IntegrationImpl.YoutubeWebScraper: {
return new YoutubeMusicWebScraper(id); return new YoutubeMusicWebScraper(id);
} }
default: { default: {
@ -142,10 +144,10 @@ function useProvideIntegrations(): Integrations {
.catch((e) => handleNotLoggedIn(auth, e)); .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) }); const id = await backend.createIntegration(v).catch((e: any) => { handleNotLoggedIn(auth, e) });
await updateFromUpstream(); await updateFromUpstream();
return id; return (id as serverApi.PostIntegrationResponse).id;
} }
let deleteIntegration = async (id: number) => { let deleteIntegration = async (id: number) => {
@ -153,7 +155,7 @@ function useProvideIntegrations(): Integrations {
await updateFromUpstream(); 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 backend.modifyIntegration(id, v).catch((e: any) => { handleNotLoggedIn(auth, e) });
await updateFromUpstream(); await updateFromUpstream();
} }

@ -1,10 +1,10 @@
import React from 'react'; 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 StoreLinkIcon from '../../../components/common/StoreLinkIcon';
import { ExternalStore } from '../../../api'; import { IntegrationWith } from '../../../api/api';
enum SearchType { enum SearchType {
Song = 'track', Track = 'track',
Artist = 'artist', Artist = 'artist',
Album = 'album', Album = 'album',
}; };
@ -33,18 +33,18 @@ export function extractInitialData(text: string): any | undefined {
return json; return json;
} }
export function parseSongs(initialData: any): IntegrationSong[] { export function parseTracks(initialData: any): IntegrationTrack[] {
try { try {
var musicResponsiveListItemRenderers: any[] = []; var musicResponsiveListItemRenderers: any[] = [];
// Scrape for any "Song"-type items. // Scrape for any "Track"-type items.
initialData.contents.sectionListRenderer.contents.forEach((c: any) => { initialData.contents.sectionListRenderer.contents.forEach((c: any) => {
if (c.musicShelfRenderer) { if (c.musicShelfRenderer) {
c.musicShelfRenderer.contents.forEach((cc: any) => { c.musicShelfRenderer.contents.forEach((cc: any) => {
if (cc.musicResponsiveListItemRenderer && if (cc.musicResponsiveListItemRenderer &&
cc.musicResponsiveListItemRenderer.flexColumns && cc.musicResponsiveListItemRenderer.flexColumns &&
cc.musicResponsiveListItemRenderer.flexColumns[1] cc.musicResponsiveListItemRenderer.flexColumns[1]
.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text === "Song") { .musicResponsiveListItemFlexColumnRenderer.text.runs[0].text === "Track") {
musicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer); musicResponsiveListItemRenderers.push(cc.musicResponsiveListItemRenderer);
} }
}) })
@ -55,7 +55,7 @@ export function parseSongs(initialData: any): IntegrationSong[] {
let videoId = s.doubleTapCommand.watchEndpoint.videoId; let videoId = s.doubleTapCommand.watchEndpoint.videoId;
let columns = s.flexColumns; 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'); throw new Error('song item doesnt match scraper expectation');
} }
let title = columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text; 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 { try {
var musicResponsiveListItemRenderers: any[] = []; 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 { try {
var musicResponsiveListItemRenderers: any[] = []; var musicResponsiveListItemRenderers: any[] = [];
@ -181,18 +181,18 @@ export default class YoutubeMusicWebScraper extends Integration {
getFeatures(): IntegrationFeature[] { getFeatures(): IntegrationFeature[] {
return [ return [
IntegrationFeature.Test, IntegrationFeature.Test,
IntegrationFeature.SearchSong, IntegrationFeature.SearchTrack,
IntegrationFeature.SearchAlbum, IntegrationFeature.SearchAlbum,
IntegrationFeature.SearchArtist, IntegrationFeature.SearchArtist,
] ]
} }
getIcon(props: any) { getIcon(props: any) {
return <StoreLinkIcon whichStore={ExternalStore.YoutubeMusic} {...props} /> return <StoreLinkIcon whichStore={IntegrationWith.YoutubeMusic} {...props} />
} }
providesStoreLink() { providesStoreLink() {
return ExternalStore.YoutubeMusic; return IntegrationWith.YoutubeMusic;
} }
async test(testParams: {}) { 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')}`); `/integrations/${this.integrationId}/search?q=${encodeURIComponent('No One Knows Queens Of The Stone Age')}`);
let text = await response.text(); 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") { 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."); throw new Error("Test failed; No One Knows was not correctly identified.");
} }
} }
async searchSong(query: string, limit: number): Promise<IntegrationSong[]> { async searchTrack(query: string, limit: number): Promise<IntegrationTrack[]> {
const response = await fetch( const response = await fetch(
(process.env.REACT_APP_BACKEND || "") + (process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`); `/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`);
let text = await response.text(); let text = await response.text();
return parseSongs(extractInitialData(text)); return parseTracks(extractInitialData(text));
} }
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> { async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> {
const response = await fetch( const response = await fetch(

@ -1,4 +1,4 @@
import * as serverApi from '../../api'; import * as serverApi from '../../api/api';
export enum QueryLeafBy { export enum QueryLeafBy {
ArtistName = 0, ArtistName = 0,
@ -7,9 +7,9 @@ export enum QueryLeafBy {
AlbumId, AlbumId,
TagInfo, TagInfo,
TagId, TagId,
SongTitle, TrackName,
SongId, TrackId,
SongStoreLinks, TrackStoreLinks,
ArtistStoreLinks, ArtistStoreLinks,
AlbumStoreLinks, AlbumStoreLinks,
} }
@ -179,32 +179,32 @@ export function simplify(q: QueryElem | null): QueryElem | null {
export function toApiQuery(q: QueryElem) : serverApi.Query { export function toApiQuery(q: QueryElem) : serverApi.Query {
const propsMapping: any = { const propsMapping: any = {
[QueryLeafBy.SongTitle]: serverApi.QueryElemProperty.songTitle, [QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
[QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName, [QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
[QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName, [QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
[QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId, [QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId,
[QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId, [QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId,
[QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId, [QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId,
[QueryLeafBy.SongId]: serverApi.QueryElemProperty.songId, [QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.SongStoreLinks]: serverApi.QueryElemProperty.songStoreLinks, [QueryLeafBy.TrackStoreLinks]: serverApi.QueryElemProperty.trackStoreLinks,
[QueryLeafBy.ArtistStoreLinks]: serverApi.QueryElemProperty.artistStoreLinks, [QueryLeafBy.ArtistStoreLinks]: serverApi.QueryElemProperty.artistStoreLinks,
[QueryLeafBy.AlbumStoreLinks]: serverApi.QueryElemProperty.albumStoreLinks, [QueryLeafBy.AlbumStoreLinks]: serverApi.QueryElemProperty.albumStoreLinks,
} }
const leafOpsMapping: any = { const leafOpsMapping: any = {
[QueryLeafOp.Equals]: serverApi.QueryFilterOp.Eq, [QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryFilterOp.Like, [QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
} }
const nodeOpsMapping: any = { const nodeOpsMapping: any = {
[QueryNodeOp.And]: serverApi.QueryElemOp.And, [QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryElemOp.Or, [QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryElemOp.Not, [QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
} }
if(isLeafElem(q) && isTagQueryInfo(q.b)) { if(isLeafElem(q) && isTagQueryInfo(q.b)) {
// Special case for tag queries by ID // Special case for tag queries by ID
const r: serverApi.QueryElem = { const r: serverApi.QueryElem = {
prop: serverApi.QueryElemProperty.tagId, prop: serverApi.QueryElemProperty.tagId,
propOperator: serverApi.QueryFilterOp.In, propOperator: serverApi.QueryLeafOp.In,
propOperand: q.b.matchIds, propOperand: q.b.matchIds,
} }
return r; return r;

@ -1,42 +1,42 @@
import * as serverApi from '../api'; import * as serverApi from '../api/api';
import backendRequest from './backend/request'; 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 = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change), 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) const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) { 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 = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change), 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) const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) { if(!response.ok) {
throw new Error("Failed to save artist changes: " + response.statusText); 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 = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change), 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) const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) { if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText); throw new Error("Failed to save album changes: " + response.statusText);

@ -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));
},
}

@ -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));
},
}

@ -3,7 +3,7 @@
import React, { useState, useContext, createContext, ReactFragment } from "react"; import React, { useState, useContext, createContext, ReactFragment } from "react";
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../api'; import * as serverApi from '../api/api';
export interface AuthUser { export interface AuthUser {
id: number, id: number,

@ -1,16 +1,14 @@
const bodyParser = require('body-parser'); 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 Knex from 'knex';
import { Query } from './endpoints/Query'; import { queryEndpoints } from './endpoints/Query';
import { artistEndpoints } from './endpoints/Artist';
import { PostArtist, PutArtist, GetArtist } from './endpoints/Artist'; import { albumEndpoints } from './endpoints/Album';
import { PostAlbum, PutAlbum, GetAlbum } from './endpoints/Album'; import { trackEndpoints } from './endpoints/Track';
import { PostSong, PutSong, GetSong } from './endpoints/Song'; import { tagEndpoints } from './endpoints/Tag';
import { PostTag, PutTag, GetTag, DeleteTag, MergeTag } from './endpoints/Tag'; import { integrationEndpoints } from './endpoints/Integration';
import { PostIntegration, PutIntegration, GetIntegration, DeleteIntegration, ListIntegrations } from './endpoints/Integration'; import { userEndpoints } from './endpoints/User';
import { RegisterUser } from './endpoints/RegisterUser';
import * as endpointTypes from './endpoints/types'; import * as endpointTypes from './endpoints/types';
import { sha512 } from 'js-sha512'; import { sha512 } from 'js-sha512';
@ -24,10 +22,10 @@ const invokeHandler = (handler: endpointTypes.EndpointHandler, knex: Knex) => {
return async (req: any, res: any) => { return async (req: any, res: any) => {
console.log("Incoming", req.method, " @ ", req.url); console.log("Incoming", req.method, " @ ", req.url);
await handler(req, res, knex) await handler(req, res, knex)
.catch(endpointTypes.catchUnhandledErrors) .catch(endpointTypes.handleErrorsInEndpoint)
.catch((_e: endpointTypes.EndpointError) => { .catch((_e: endpointTypes.EndpointError) => {
let e: endpointTypes.EndpointError = _e; let e: endpointTypes.EndpointError = _e;
console.log("Error handling request: ", e.internalMessage); console.log("Error handling request: ", e.message);
res.sendStatus(e.httpStatus); res.sendStatus(e.httpStatus);
}) })
console.log("Finished handling", req.method, "@", req.url); console.log("Finished handling", req.method, "@", req.url);
@ -104,34 +102,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
// Set up integration proxies // Set up integration proxies
app.use('/integrations', checkLogin(), createIntegrations(knex)); app.use('/integrations', checkLogin(), createIntegrations(knex));
// Set up REST API endpoints // Set up auth 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));
app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => { app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => {
res.status(200).send({ userId: req.user.id }); res.status(200).send({ userId: req.user.id });
}); });
@ -139,6 +110,26 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
req.logout(); req.logout();
res.status(200).send(); 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 } export { SetupApp }

@ -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<AlbumWithDetails> {
// Start transfers for tracks, tags and artists.
// Also request the album itself.
const tagsPromise: Promise<api.TagWithId[]> =
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<api.TrackWithId[]> =
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<api.ArtistWithId[]> =
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<api.Album | undefined> =
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<number> {
return await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise: Promise<number[]> =
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<number[]> =
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<number[]> =
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<void> {
await knex.transaction(async (trx) => {
try {
// Start retrieving the album itself.
const albumIdPromise: Promise<number | undefined> =
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<number[] | undefined> =
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<number[] | undefined> =
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<void> {
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<any> =
trx.delete()
.from('artists_albums')
.where({ 'albumId': albumId });
// Start deleting tag associations with the album.
const deleteTagsPromise: Promise<any> =
trx.delete()
.from('albums_tags')
.where({ 'albumId': albumId });
// Start deleting track associations with the album.
const deleteTracksPromise: Promise<any> =
trx.delete()
.from('tracks_albums')
.where({ 'albumId': albumId });
// Start deleting the album.
const deleteAlbumPromise: Promise<any> =
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;
}
})
}

@ -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<ArtistWithDetails> {
// Start transfers for tags and albums.
// Also request the artist itself.
const tagsPromise: Promise<api.TagWithId[]> =
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<api.AlbumWithId[]> =
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<api.Artist | undefined> =
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<number> {
return await knex.transaction(async (trx) => {
try {
// Start retrieving albums.
const albumIdsPromise: Promise<number[]> =
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<number[]> =
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<void> {
await knex.transaction(async (trx) => {
try {
// Start retrieving the artist itself.
const artistIdPromise: Promise<number | undefined> =
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<number[] | undefined> =
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<void> {
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<any> =
trx.delete()
.from('artists_albums')
.where({ 'artistId': artistId });
// Start deleting tag associations with the artist.
const deleteTagsPromise: Promise<any> =
trx.delete()
.from('artists_tags')
.where({ 'artistId': artistId });
// Start deleting track associations with the artist.
const deleteTracksPromise: Promise<any> =
trx.delete()
.from('tracks_artists')
.where({ 'artistId': artistId });
// Start deleting the artist.
const deleteArtistPromise: Promise<any> =
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;
}
})
}

@ -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<DBImportExportFormat> {
// First, retrieve all the objects without taking linking tables into account.
// Fetch the links separately.
let tracksPromise: Promise<api.TrackWithRefsWithId[]> =
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<api.AlbumWithRefsWithId[]> =
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<api.ArtistWithRefsWithId[]> =
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<api.TagWithRefsWithId[]> =
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<void> {
return await knex.transaction(async (trx) => {
// Store the ID mappings in this record.
let tagIdMaps: Record<number, number> = {};
let artistIdMaps: Record<number, number> = {};
let albumIdMaps: Record<number, number> = {};
let trackIdMaps: Record<number, number> = {};
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();
}
});
}

@ -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<number> {
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<api.IntegrationData> {
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<api.IntegrationDataWithId[]> {
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<void> {
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();
}
})
}

@ -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 <api.Tag>{
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 <api.Artist>{
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 <api.Track>{
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 <api.Album>{
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, ObjectType> = {
[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, string> = {
[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, string> = {
[ObjectType.Album]: 'albumId',
[ObjectType.Artist]: 'artistId',
[ObjectType.Track]: 'trackId',
[ObjectType.Tag]: 'tagId',
}
function getRequiredDatabaseObjects(queryElem: api.QueryElem): Set<ObjectType> {
if (queryElem.prop) {
// Leaf node.
return new Set([propertyObjects[queryElem.prop]]);
} else if (queryElem.children) {
// Branch node.
var r = new Set<ObjectType>();
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<any, string> = {
[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<number, any[]> = {};
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<any> {
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<api.QueryResponse> {
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<any> = (artistLimit && artistLimit !== 0) ?
constructQuery(knex,
userId,
ObjectType.Artist,
q.query,
q.ordering,
artistOffset || 0,
artistLimit >= 0 ? artistLimit : null,
) :
(async () => [])();
const albumsPromise: Promise<any> = (albumLimit && albumLimit !== 0) ?
constructQuery(knex,
userId,
ObjectType.Album,
q.query,
q.ordering,
artistOffset || 0,
albumLimit >= 0 ? albumLimit : null,
) :
(async () => [])();
const tracksPromise: Promise<any> = (trackLimit && trackLimit !== 0) ?
constructQuery(knex,
userId,
ObjectType.Track,
q.query,
q.ordering,
trackOffset || 0,
trackLimit >= 0 ? trackLimit : null,
) :
(async () => [])();
const tagsPromise: Promise<any> = (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<Record<number, any[]>> = (trackLimit && trackLimit !== 0) ?
(async () => {
return await getLinkedObjects(knex, userId, ObjectType.Track, ObjectType.Artist, await trackIdsPromise);
})() :
(async () => { return {}; })();
const tracksTagsPromise: Promise<Record<number, any[]>> = (trackLimit && trackLimit !== 0) ?
(async () => {
const tagsPerTrack: Record<number, any> = await getLinkedObjects(knex, userId, ObjectType.Track, ObjectType.Tag, await trackIdsPromise);
var result: Record<number, any> = {};
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<Record<number, any[]>> = (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;
}

@ -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<number[]> {
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<number> {
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<any> =
trx.delete()
.from('artists_tags')
.whereIn('tagId', toDelete);
// Start deleting album associations with the tag.
const deleteAlbumsPromise: Promise<any> =
trx.delete()
.from('albums_tags')
.whereIn('tagId', toDelete);
// Start deleting track associations with the tag.
const deleteTracksPromise: Promise<any> =
trx.delete()
.from('tracks_tags')
.whereIn('tagId', toDelete);
// Start deleting the tag and its children.
const deleteTags: Promise<any> = 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<TagWithDetails> {
const tagPromise: Promise<TagWithRefsWithId | undefined> =
knex.select(['id', 'name', 'parentId'])
.from('tags')
.where({ 'user': userId })
.where({ 'id': tagId })
.then((r: TagWithRefsWithId[] | undefined) => r ? r[0] : undefined);
const parentPromise: Promise<TagWithId | null> =
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<void> {
await knex.transaction(async (trx) => {
try {
// Start retrieving the parent tag.
const parentTagIdPromise: Promise<number | undefined | null> = 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<void> {
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;
}
})
}

@ -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<TrackWithDetails> {
// Start transfers for tracks, tags and artists.
// Also request the track itself.
const tagsPromise: Promise<api.TagWithId[]> =
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<api.ArtistWithId[]> =
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<api.Track | undefined> =
knex.select('name', 'storeLinks')
.from('tracks')
.where({ 'user': userId })
.where({ id: id })
.then((tracks: any) => tracks[0]);
const albumPromise: Promise<api.AlbumWithId | null> =
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<number> {
return await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise: Promise<number[]> =
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<number[]> =
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<number | null> =
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<void> {
await knex.transaction(async (trx) => {
try {
// Start retrieving the track itself.
const trackIdPromise: Promise<number | undefined> =
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<number[] | undefined> =
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<void> {
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<any> =
trx.delete()
.from('artists_tracks')
.where({ 'trackId': trackId });
// Start deleting tag associations with the track.
const deleteTagsPromise: Promise<any> =
trx.delete()
.from('tracks_tags')
.where({ 'trackId': trackId });
// Start deleting the track.
const deleteTrackPromise: Promise<any> =
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;
}
})
}

@ -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<number> {
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();
}
})
}

@ -1,314 +1,113 @@
import * as api from '../../client/src/api'; import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex'; import Knex from 'knex';
import asJson from '../lib/asJson'; 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) => { 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; const { id: userId } = req.user;
try { try {
// Start transfers for songs, tags and artists. const maybeAlbum: api.GetAlbumResponse | null =
// Also request the album itself. await getAlbum(req.params.id, userId, knex);
const tagsPromise: Promise<api.TagDetailsResponseWithId[]> = 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<api.SongDetailsResponseWithId[]> = knex.select('songId')
.from('songs_albums')
.where({ 'albumId': req.params.id })
.then((songs: any) => {
return songs.map((song: any) => song['songId'])
})
.then((ids: number[]) => knex.select(['id', 'title', 'storeLinks'])
.from('songs')
.whereIn('id', ids));
const artistsPromise = knex.select('artistId') if (maybeAlbum) {
.from('artists_albums') await res.send(maybeAlbum);
.where({ 'albumId': req.params.id })
.then((artists: any) => {
return artists.map((artist: any) => artist['artistId'])
})
.then((ids: number[]) => knex.select(['id', 'name', 'storeLinks'])
.from('artists')
.whereIn('id', ids));
const albumPromise = knex.select('name', 'storeLinks')
.from('albums')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((albums: any) => albums[0]);
// Wait for the requests to finish.
const [album, tags, songs, artists] =
await Promise.all([albumPromise, tagsPromise, songsPromise, artistsPromise]);
// Respond to the request.
if (album) {
const response: api.AlbumDetailsResponse = {
name: album['name'],
artists: artists,
tags: tags,
songs: songs,
storeLinks: asJson(album['storeLinks']),
};
await res.send(response);
} else { } else {
await res.status(404).send({}); await res.status(404).send({});
} }
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
} }
} }
export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateAlbumRequest(req)) { if (!api.checkPostAlbumRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PostAlbum request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.CreateAlbumRequest = req.body; const reqObject: api.PostAlbumRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Post Album ", reqObject); console.log("User ", userId, ": Post Album ", reqObject);
await knex.transaction(async (trx) => {
try { try {
// Start retrieving artists. let id = await createAlbum(userId, reqObject, knex);
const artistIdsPromise = reqObject.artistIds ? res.status(200).send(id);
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) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyAlbumRequest(req)) { if (!api.checkPutAlbumRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PutAlbum request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.ModifyAlbumRequest = req.body; const reqObject: api.PutAlbumRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Put Album ", reqObject); console.log("User ", userId, ": Put Album ", reqObject);
await knex.transaction(async (trx) => {
try { try {
modifyAlbum(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
// Start retrieving the album itself. export const PatchAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const albumPromise = trx.select('id') if (!api.checkPatchAlbumRequest(req)) {
.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 = { const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for ModifyAlbum request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PatchAlbum request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.PatchAlbumRequest = req.body;
const { id: userId } = req.user;
// Modify the album. console.log("User ", userId, ": Patch Album ", reqObject);
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifyAlbumPromise = trx('albums')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update(update)
// Remove unlinked artists.
// TODO: test this!
const removeUnlinkedArtists = artists ? trx('artists_albums')
.where({ 'albumId': req.params.id })
.whereNotIn('artistId', reqObject.artistIds || [])
.delete() : undefined;
// Remove unlinked tags.
// TODO: test this!
const removeUnlinkedTags = tags ? trx('albums_tags')
.where({ 'albumId': req.params.id })
.whereNotIn('tagId', reqObject.tagIds || [])
.delete() : undefined;
// Link new artists. try {
// TODO: test this! modifyAlbum(userId, req.params.id, reqObject, knex);
const addArtists = artists ? trx('artists_albums') res.status(200).send();
.where({ 'albumId': req.params.id }) } catch (e) {
.then((as: any) => as.map((a: any) => a['artistId'])) handleErrorsInEndpoint(e);
.then((doneArtistIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = artists.filter((id: number) => {
return !doneArtistIds.includes(id);
});
const insertObjects = toLink.map((artistId: number) => {
return {
artistId: artistId,
albumId: req.params.id,
} }
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('artists_albums').insert(obj)
)
);
}) : undefined;
// Link new tags.
// TODO: test this!
const addTags = tags ? trx('albums_tags')
.where({ 'albumId': req.params.id })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
albumId: req.params.id,
} }
})
// Link them export const DeleteAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
return Promise.all( const { id: userId } = req.user;
insertObjects.map((obj: any) =>
trx('albums_tags').insert(obj)
)
);
}) : undefined;
// Wait for all operations to finish. console.log("User ", userId, ": Delete Album ", req.params.id);
await Promise.all([
modifyAlbumPromise,
removeUnlinkedArtists,
removeUnlinkedTags,
addArtists,
addTags
]);
// Respond to the request. try {
await deleteAlbum(userId, req.params.id, knex);
res.status(200).send(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
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 ],
];

@ -1,221 +1,108 @@
import * as api from '../../client/src/api'; import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex'; import Knex from 'knex';
import asJson from '../lib/asJson'; import asJson from '../lib/asJson';
import { createArtist, deleteArtist, getArtist, modifyArtist } from '../db/Artist';
export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { 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; const { id: userId } = req.user;
try { try {
const tags: api.TagDetailsResponseWithId[] = await knex.select('tagId') let artist = await getArtist(req.params.id, userId, knex);
.from('artists_tags') await res.status(200).send(artist);
.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({});
}
} catch (e) { } catch (e) {
catchUnhandledErrors(e) handleErrorsInEndpoint(e)
} }
} }
export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateArtistRequest(req)) { if (!api.checkPostArtistRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PostArtist request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PostArtist request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.CreateArtistRequest = req.body; const reqObject: api.PostArtistRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Create artist ", reqObject) console.log("User ", userId, ": Create artist ", reqObject)
await knex.transaction(async (trx) => {
try { try {
// Retrieve tag instances to link the artist to. const id = await createArtist(userId, reqObject, knex);
const tags: number[] = reqObject.tagIds ? await res.status(200).send({ id: id });
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) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
});
} }
export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyArtistRequest(req)) { if (!api.checkPutArtistRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PutArtist request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PutArtist request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.ModifyArtistRequest = req.body; const reqObject: api.PutArtistRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Put Artist ", reqObject); console.log("User ", userId, ": Put Artist ", reqObject);
await knex.transaction(async (trx) => {
try { try {
const artistId = parseInt(req.params.id); await modifyArtist(userId, req.params.id, reqObject, knex);
res.status(200).send();
// Start retrieving the artist itself.
const artistPromise = trx.select('id') } catch (e) {
.from('artists') handleErrorsInEndpoint(e);
.where({ 'user': userId }) }
.where({ id: artistId }) }
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
export const PatchArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
// Start retrieving tags. if (!api.checkPatchArtistRequest(req)) {
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 = { const e: EndpointError = {
internalMessage: 'Not all artists and/or tags exist for ModifyArtist request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PatchArtist request',
httpStatus: 400 httpStatus: 400
}; };
throw e; 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();
// Modify the artist. } catch (e) {
var update: any = {}; handleErrorsInEndpoint(e);
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,
} }
})
export const DeleteArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
// Link them const { id: userId } = req.user;
return Promise.all(
insertObjects.map((obj: any) => console.log("User ", userId, ": Delete Artist ", req.params.id);
trx('artists_tags').insert(obj)
) try {
); await deleteArtist(userId, req.params.id, knex);
}) : undefined;
// Wait for all operations to finish.
await Promise.all([
modifyArtistPromise,
removeUnlinkedTags,
addTags
]);
// Respond to the request.
res.status(200).send(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
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 ],
];

@ -1,206 +1,122 @@
import * as api from '../../client/src/api'; import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex'; import Knex from 'knex';
import asJson from '../lib/asJson'; 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) => { export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateIntegrationRequest(req)) { if (!api.checkPostIntegrationRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PostIntegration request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PostIntegration request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.CreateIntegrationRequest = req.body; const reqObject: api.PostIntegrationRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Post Integration ", reqObject); console.log("User ", userId, ": Post Integration ", reqObject);
await knex.transaction(async (trx) => {
try { try {
// Create the new integration. let id = await createIntegration(userId, reqObject, knex);
var integration: any = { const responseObject: api.PostIntegrationResponse = {
name: reqObject.name, id: id
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); res.status(200).send(responseObject);
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { 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
};
throw e;
}
const { id: userId } = req.user;
try { try {
const integration = (await knex.select(['id', 'name', 'type', 'details']) let integration = await getIntegration(req.user.id, req.params.id, knex);
.from('integrations') res.status(200).send(integration);
.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({});
}
} catch (e) { } catch (e) {
catchUnhandledErrors(e) handleErrorsInEndpoint(e)
} }
} }
export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex: Knex) => { 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; const { id: userId } = req.user;
console.log("List integrations"); console.log("List integrations");
try { try {
const integrations: api.ListIntegrationsResponse = ( const integrations: IntegrationDataWithId[] = await listIntegrations(req.user.id, knex);
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),
}
})
console.log("Found integrations:", integrations); console.log("Found integrations:", integrations);
await res.send(integrations); await res.status(200).send(integrations);
} catch (e) { } catch (e) {
catchUnhandledErrors(e) handleErrorsInEndpoint(e)
} }
} }
export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteIntegrationRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Delete Integration ", reqObject); console.log("User ", userId, ": Delete Integration ", req.params.id);
await knex.transaction(async (trx) => {
try { try {
// Start retrieving the integration itself. await deleteIntegration(userId, req.params.id, knex);
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(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyIntegrationRequest(req)) { if (!api.checkPutIntegrationRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PutIntegration request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PutIntegration request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.ModifyIntegrationRequest = req.body; const reqObject: api.PutIntegrationRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Put Integration ", reqObject); console.log("User ", userId, ": Put Integration ", reqObject);
await knex.transaction(async (trx) => {
try { try {
// Start retrieving the integration. await modifyIntegration(userId, req.params.id, reqObject, knex);
const integrationId = await trx.select('id') res.status(200).send();
.from('integrations')
.where({ 'user': userId }) } catch (e) {
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) handleErrorsInEndpoint(e);
}
// Check that we found all objects we need. }
if (!integrationId) {
export const PatchIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchIntegrationRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body), name: "EndpointError",
httpStatus: 404 message: 'Invalid PatchIntegration request',
httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.PatchIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Patch Integration ", reqObject);
// Modify the integration. try {
var update: any = {}; await modifyIntegration(userId, req.params.id, reqObject, knex);
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(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
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],
];

@ -1,281 +1,13 @@
import * as api from '../../client/src/api'; import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex'; import Knex from 'knex';
import asJson from '../lib/asJson'; import { doQuery } from '../db/Query';
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, ObjectType> = {
[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, string> = {
[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, string> = {
[ObjectType.Album]: 'albumId',
[ObjectType.Artist]: 'artistId',
[ObjectType.Song]: 'songId',
[ObjectType.Tag]: 'tagId',
}
function getRequiredDatabaseObjects(queryElem: api.QueryElem): Set<ObjectType> {
if (queryElem.prop) {
// Leaf node.
return new Set([propertyObjects[queryElem.prop]]);
} else if (queryElem.children) {
// Branch node.
var r = new Set<ObjectType>();
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<any, string> = {
[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<number, any[]> = {};
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<any> {
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 const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkQueryRequest(req.body)) { if (!api.checkQueryRequest(req.body)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid Query request: ' + JSON.stringify(req.body),
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
@ -286,164 +18,13 @@ export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) =>
console.log("User ", userId, ": Query ", reqObject); console.log("User ", userId, ": Query ", reqObject);
try { try {
const songLimit = reqObject.offsetsLimits.songLimit; let r = doQuery(userId, reqObject, knex);
const songOffset = reqObject.offsetsLimits.songOffset; res.status(200).send(r);
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<any> = (artistLimit && artistLimit !== 0) ?
constructQuery(knex,
userId,
ObjectType.Artist,
reqObject.query,
reqObject.ordering,
artistOffset || 0,
artistLimit >= 0 ? artistLimit : null,
) :
(async () => [])();
const albumsPromise: Promise<any> = (albumLimit && albumLimit !== 0) ?
constructQuery(knex,
userId,
ObjectType.Album,
reqObject.query,
reqObject.ordering,
artistOffset || 0,
albumLimit >= 0 ? albumLimit : null,
) :
(async () => [])();
const songsPromise: Promise<any> = (songLimit && songLimit !== 0) ?
constructQuery(knex,
userId,
ObjectType.Song,
reqObject.query,
reqObject.ordering,
songOffset || 0,
songLimit >= 0 ? songLimit : null,
) :
(async () => [])();
const tagsPromise: Promise<any> = (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<Record<number, any[]>> = (songLimit && songLimit !== 0) ?
(async () => {
return await getLinkedObjects(knex, userId, ObjectType.Song, ObjectType.Artist, await songIdsPromise);
})() :
(async () => { return {}; })();
const songsTagsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit !== 0) ?
(async () => {
const tagsPerSong: Record<number, any> = await getLinkedObjects(knex, userId, ObjectType.Song, ObjectType.Tag, await songIdsPromise);
var result: Record<number, any> = {};
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<Record<number, any[]>> = (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);
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
} }
} }
export const queryEndpoints: [ string, string, boolean, EndpointHandler ][] = [
[ api.QueryEndpoint, 'post', true, Query ],
];

@ -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();
}
})
}

@ -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<api.TagDetailsResponseWithId[]> = 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<api.AlbumDetailsResponseWithId[]> = 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<api.ArtistDetailsResponseWithId[]> = 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();
}
})
}

@ -1,306 +1,124 @@
import * as api from '../../client/src/api'; import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex'; import Knex from 'knex';
import { createTag, deleteTag, getTag, mergeTag, modifyTag } from '../db/Tag';
export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateTagRequest(req)) { if (!api.checkPostTagRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PostTag request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PostTag request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.CreateTagRequest = req.body; const reqObject: api.PostTagRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Post Tag ", reqObject); console.log("User ", userId, ": Post Tag ", reqObject);
await knex.transaction(async (trx) => {
try { 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. // Respond to the request.
const responseObject: api.CreateTagResponse = { const responseObject: api.PostTagResponse = {
id: tagId id: await createTag(userId, reqObject, knex)
}; };
res.status(200).send(responseObject); res.status(200).send(responseObject);
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
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) => { 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; 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 { 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. deleteTag(userId, req.params.id, knex);
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();
// Respond to the request.
res.status(200).send(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { 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; const { id: userId } = req.user;
try { try {
const results = await knex.select(['id', 'name', 'parentId']) let tag = await getTag(req.params.id, userId, knex);
.from('tags') await res.status(200).send(tag);
.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({});
}
} catch (e) { } catch (e) {
catchUnhandledErrors(e) handleErrorsInEndpoint(e)
} }
} }
export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyTagRequest(req)) { if (!api.checkPutTagRequest(req)) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Invalid PutTag request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PutTag request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.ModifyTagRequest = req.body; const reqObject: api.PutTagRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Put Tag ", reqObject); console.log("User ", userId, ": Put Tag ", reqObject);
await knex.transaction(async (trx) => {
try { try {
// Start retrieving the parent tag. await modifyTag(userId, req.params.id, reqObject, knex);
const parentTagPromise = reqObject.parentId ? res.status(200).send();
trx.select('id') } catch (e) {
.from('tags') handleErrorsInEndpoint(e);
.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. export const PatchTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if ((reqObject.parentId && !parent) || if (!api.checkPatchTagRequest(req)) {
!tag) {
const e: EndpointError = { const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body), name: "EndpointError",
message: 'Invalid PatchTag request',
httpStatus: 400 httpStatus: 400
}; };
throw e; throw e;
} }
const reqObject: api.PatchTagRequest = req.body;
const { id: userId } = req.user;
// Modify the tag. console.log("User ", userId, ": Patch Tag ", reqObject);
await trx('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update({
name: reqObject.name,
parentId: reqObject.parentId || null,
})
// Respond to the request. try {
await modifyTag(userId, req.params.id, reqObject, knex);
res.status(200).send(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => { export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkMergeTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const { id: userId } = req.user; const { id: userId } = req.user;
console.log("User ", userId, ": Merge Tag ", reqObject); console.log("User ", userId, ": Merge Tag ", req.params.id, req.params.toId);
const fromId = req.params.id; const fromId = req.params.id;
const toId = req.params.toId; const toId = req.params.toId;
await knex.transaction(async (trx) => {
try { try {
// Start retrieving the "from" tag. mergeTag(userId, fromId, toId, knex);
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)
// Wait for the requests to finish.
var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]);
// 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;
}
// 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();
// Respond to the request.
res.status(200).send(); res.status(200).send();
} catch (e) { } catch (e) {
catchUnhandledErrors(e); handleErrorsInEndpoint(e);
trx.rollback();
} }
})
} }
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 ],
];

@ -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 ],
];

@ -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 ],
];

@ -2,26 +2,49 @@ import Knex from 'knex';
export type EndpointHandler = (req: any, res: any, knex: Knex) => Promise<void>; export type EndpointHandler = (req: any, res: any, knex: Knex) => Promise<void>;
export interface EndpointError { export interface EndpointError extends Error {
internalMessage: String; name: "EndpointError",
message: string;
httpStatus: Number; 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 { export function isEndpointError(obj: any): obj is EndpointError {
return obj.internalMessage !== undefined && return obj.name === "EndpointError";
obj.httpStatus !== undefined;
} }
export const catchUnhandledErrors = (_e: any) => { export function isDBError(obj: any): obj is DBError {
if (isEndpointError(_e)) { return obj.name === "DBError";
// Rethrow
throw _e;
} }
// This is an unhandled error, make an internal server error out of it. export function toEndpointError(e: Error): EndpointError {
const e: EndpointError = { if (isEndpointError(e)) { return e; }
internalMessage: _e, else if (isDBError(e) && e.kind === DBErrorKind.ResourceNotFound) {
httpStatus: 500 return {
name: "EndpointError",
message: e.message,
httpStatus: 404,
}
} }
throw e;
return {
name: "EndpointError",
message: e.message,
httpStatus: 500,
}
}
export const handleErrorsInEndpoint = (_e: any) => {
throw toEndpointError(_e);
} }

@ -1,5 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { IntegrationType } from "../../client/src/api"; import { IntegrationImpl } from "../../client/src/api/api";
const { createProxyMiddleware } = require('http-proxy-middleware'); const { createProxyMiddleware } = require('http-proxy-middleware');
let axios = require('axios') let axios = require('axios')
@ -80,7 +80,7 @@ export function createIntegrations(knex: Knex) {
req._integration.secretDetails = JSON.parse(req._integration.secretDetails); req._integration.secretDetails = JSON.parse(req._integration.secretDetails);
switch (req._integration.type) { switch (req._integration.type) {
case IntegrationType.SpotifyClientCredentials: { case IntegrationImpl.SpotifyClientCredentials: {
console.log("Integration: ", req._integration) console.log("Integration: ", req._integration)
// FIXME: persist the token // FIXME: persist the token
req._access_token = await getSpotifyCCAuthToken( req._access_token = await getSpotifyCCAuthToken(
@ -93,7 +93,7 @@ export function createIntegrations(knex: Knex) {
req.headers["Authorization"] = "Bearer " + req._access_token; req.headers["Authorization"] = "Bearer " + req._access_token;
return proxySpotifyCC(req, res, next); return proxySpotifyCC(req, res, next);
} }
case IntegrationType.YoutubeWebScraper: { case IntegrationImpl.YoutubeWebScraper: {
console.log("Integration: ", req._integration) console.log("Integration: ", req._integration)
return proxyYoutubeMusic(req, res, next); return proxyYoutubeMusic(req, res, next);
} }

@ -1,44 +0,0 @@
import * as api from '../../client/src/api';
import asJson from './asJson';
export function toApiTag(dbObj: any): api.TagDetails {
return <api.TagDetails>{
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 <api.ArtistDetails>{
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 <api.SongDetails>{
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 <api.AlbumDetails>{
albumId: dbObj['albums.id'],
name: dbObj['albums.name'],
storeLinks: asJson(dbObj['albums.storeLinks']),
};
}

@ -2,13 +2,15 @@ import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
// Songs table. // tracks table.
await knex.schema.createTable( await knex.schema.createTable(
'songs', 'tracks',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.string('title'); table.string('name');
table.json('storeLinks') 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<void> {
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.string('name'); 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<void> {
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.string('name'); 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<void> {
table.increments('id'); table.increments('id');
table.string('name'); table.string('name');
table.integer('parentId'); table.integer('parentId');
table.integer('user').unsigned().notNullable().defaultTo(1);
} }
) )
// Songs <-> Artists // Users table.
await knex.schema.createTable( await knex.schema.createTable(
'songs_artists', 'users',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('songId'); table.string('email');
table.integer('artistId'); table.string('passwordHash')
} }
) )
// Songs <-> Albums // Integrations table.
await knex.schema.createTable( await knex.schema.createTable(
'songs_albums', 'integrations',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('songId'); table.integer('user').unsigned().notNullable().defaultTo(1);
table.integer('albumId'); 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( await knex.schema.createTable(
'songs_tags', 'tracks_tags',
(table: any) => { (table: any) => {
table.increments('id'); table.increments('id');
table.integer('songId'); table.integer('trackId');
table.integer('tagId'); table.integer('tagId');
table.unique(['trackId', 'tagId'])
} }
) )
@ -79,6 +100,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.integer('artistId'); table.integer('artistId');
table.integer('tagId'); table.integer('tagId');
table.unique(['artistId', 'tagId'])
} }
) )
@ -89,6 +111,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.integer('tagId'); table.integer('tagId');
table.integer('albumId'); table.integer('albumId');
table.unique(['albumId', 'tagId'])
} }
) )
@ -99,21 +122,24 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id'); table.increments('id');
table.integer('artistId'); table.integer('artistId');
table.integer('albumId'); table.integer('albumId');
table.unique(['artistId', 'albumId'])
} }
) )
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('songs'); await knex.schema.dropTable('tracks');
await knex.schema.dropTable('artists'); await knex.schema.dropTable('artists');
await knex.schema.dropTable('albums'); await knex.schema.dropTable('albums');
await knex.schema.dropTable('tags'); await knex.schema.dropTable('tags');
await knex.schema.dropTable('songs_artists'); await knex.schema.dropTable('tracks_artists');
await knex.schema.dropTable('songs_albums'); await knex.schema.dropTable('tracks_albums');
await knex.schema.dropTable('songs_tags'); await knex.schema.dropTable('tracks_tags');
await knex.schema.dropTable('artists_tags'); await knex.schema.dropTable('artists_tags');
await knex.schema.dropTable('albums_tags'); await knex.schema.dropTable('albums_tags');
await knex.schema.dropTable('artists_albums'); await knex.schema.dropTable('artists_albums');
await knex.schema.dropTable('users');
await knex.schema.dropTable('integrations');
} }

@ -1,73 +0,0 @@
import * as Knex from "knex";
import { sha512 } from "js-sha512";
export async function up(knex: Knex): Promise<void> {
// 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<void> {
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');
}
)
}

@ -1,24 +0,0 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> {
// 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<void> {
await knex.schema.dropTable('integrations');
}

@ -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<void> {
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<void> {
await revertStoreLinks('songs', knex);
await revertStoreLinks('albums', knex);
await revertStoreLinks('artists', knex);
}

@ -4,7 +4,7 @@ const express = require('express');
import { SetupApp } from '../../../app'; import { SetupApp } from '../../../app';
import * as helpers from './helpers'; import * as helpers from './helpers';
import { sha512 } from 'js-sha512'; import { sha512 } from 'js-sha512';
import { IntegrationType } from '../../../../client/src/api'; import { IntegrationImpl } from '../../../../client/src/api';
async function init() { async function init() {
chai.use(chaiHttp); chai.use(chaiHttp);
@ -30,10 +30,10 @@ describe('POST /integration with missing or wrong data', () => {
let agent = await init(); let agent = await init();
let req = agent.keepOpen(); let req = agent.keepOpen();
try { 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", details: {}, secretDetails: {} }, 400);
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, secretDetails: {} }, 400); await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, secretDetails: {} }, 400);
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, }, 400); await helpers.createIntegration(req, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {}, }, 400);
await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400); await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400);
} finally { } finally {
req.close(); req.close();
@ -48,7 +48,7 @@ describe('POST /integration with a correct request', () => {
let agent = await init(); let agent = await init();
let req = agent.keepOpen(); let req = agent.keepOpen();
try { 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 { } finally {
req.close(); req.close();
agent.close(); agent.close();
@ -62,9 +62,9 @@ describe('PUT /integration with a correct request', () => {
let agent = await init(); let agent = await init();
let req = agent.keepOpen(); let req = agent.keepOpen();
try { 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: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200); await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200);
await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' } }) await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: { secret: 'cat' } })
} finally { } finally {
req.close(); req.close();
agent.close(); agent.close();
@ -78,7 +78,7 @@ describe('PUT /integration with wrong data', () => {
let agent = await init(); let agent = await init();
let req = agent.keepOpen(); let req = agent.keepOpen();
try { 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); await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {}, secretDetails: {} }, 400);
} finally { } finally {
req.close(); req.close();
@ -93,8 +93,8 @@ describe('DELETE /integration with a correct request', () => {
let agent = await init(); let agent = await init();
let req = agent.keepOpen(); let req = agent.keepOpen();
try { 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.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }) await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {} })
await helpers.deleteIntegration(req, 1, 200); await helpers.deleteIntegration(req, 1, 200);
await helpers.checkIntegration(req, 1, 404); await helpers.checkIntegration(req, 1, 404);
} finally { } finally {
@ -110,13 +110,13 @@ describe('GET /integration list with a correct request', () => {
let agent = await init(); let agent = await init();
let req = agent.keepOpen(); let req = agent.keepOpen();
try { 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.createIntegration(req, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 }); await helpers.createIntegration(req, { name: "B", type: IntegrationImpl.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: "C", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 });
await helpers.listIntegrations(req, 200, [ await helpers.listIntegrations(req, 200, [
{ id: 1, name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }, { id: 1, name: "A", type: IntegrationImpl.SpotifyClientCredentials, details: {} },
{ id: 2, name: "B", type: IntegrationType.SpotifyClientCredentials, details: {} }, { id: 2, name: "B", type: IntegrationImpl.SpotifyClientCredentials, details: {} },
{ id: 3, name: "C", type: IntegrationType.SpotifyClientCredentials, details: {} }, { id: 3, name: "C", type: IntegrationImpl.SpotifyClientCredentials, details: {} },
]); ]);
} finally { } finally {
req.close(); req.close();

@ -1,6 +1,6 @@
import { expect } from "chai"; import { expect } from "chai";
import { sha512 } from "js-sha512"; import { sha512 } from "js-sha512";
import { IntegrationType } from "../../../../client/src/api"; import { IntegrationImpl } from "../../../../client/src/api";
export async function initTestDB() { export async function initTestDB() {
// Allow different database configs - but fall back to SQLite in memory if necessary. // 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( export async function createIntegration(
req, req,
props = { name: "Integration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, props = { name: "Integration", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} },
expectStatus = undefined, expectStatus = undefined,
expectResponse = undefined expectResponse = undefined
) { ) {
@ -266,7 +266,7 @@ export async function createIntegration(
export async function modifyIntegration( export async function modifyIntegration(
req, req,
id = 1, id = 1,
props = { name: "NewIntegration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, props = { name: "NewIntegration", type: IntegrationImpl.SpotifyClientCredentials, details: {}, secretDetails: {} },
expectStatus = undefined, expectStatus = undefined,
) { ) {
await req await req

Loading…
Cancel
Save