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. 337
      server/endpoints/Album.ts
  51. 261
      server/endpoints/Artist.ts
  52. 228
      server/endpoints/Integration.ts
  53. 445
      server/endpoints/Query.ts
  54. 49
      server/endpoints/RegisterUser.ts
  55. 382
      server/endpoints/Song.ts
  56. 320
      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 AlbumWindow from './windows/album/AlbumWindow';
import TagWindow from './windows/tag/TagWindow';
import SongWindow from './windows/song/SongWindow';
import SongWindow from './windows/track/TrackWindow';
import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow';
import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom';
import LoginWindow from './windows/login/LoginWindow';

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

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

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

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

@ -3,20 +3,20 @@ import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyle
import stringifyList from '../../lib/stringifyList';
import { useHistory } from 'react-router';
export interface SongGetters {
getTitle: (song: any) => string,
getId: (song: any) => number,
getArtistNames: (song: any) => string[],
getArtistIds: (song: any) => number[],
getAlbumNames: (song: any) => string[],
getAlbumIds: (song: any) => number[],
getTagNames: (song: any) => string[][], // Each tag is represented as a series of strings.
getTagIds: (song: any) => number[][], // Each tag is represented as a series of ids.
export interface TrackGetters {
getTitle: (track: any) => string,
getId: (track: any) => number,
getArtistNames: (track: any) => string[],
getArtistIds: (track: any) => number[],
getAlbumNames: (track: any) => string[],
getAlbumIds: (track: any) => number[],
getTagNames: (track: any) => string[][], // Each tag is represented as a series of strings.
getTagIds: (track: any) => number[][], // Each tag is represented as a series of ids.
}
export default function SongTable(props: {
songs: any[],
songGetters: SongGetters,
export default function TrackTable(props: {
tracks: any[],
trackGetters: TrackGetters,
}) {
const history = useHistory();
@ -44,17 +44,17 @@ export default function SongTable(props: {
</TableRow>
</TableHead>
<TableBody>
{props.songs.map((song: any) => {
const title = props.songGetters.getTitle(song);
{props.tracks.map((track: any) => {
const title = props.trackGetters.getTitle(track);
// TODO: display artists and albums separately!
const artistNames = props.songGetters.getArtistNames(song);
const artistNames = props.trackGetters.getArtistNames(track);
const artist = stringifyList(artistNames);
const mainArtistId = props.songGetters.getArtistIds(song)[0];
const albumNames = props.songGetters.getAlbumNames(song);
const mainArtistId = props.trackGetters.getArtistIds(track)[0];
const albumNames = props.trackGetters.getAlbumNames(track);
const album = stringifyList(albumNames);
const mainAlbumId = props.songGetters.getAlbumIds(song)[0];
const songId = props.songGetters.getId(song);
const tagIds = props.songGetters.getTagIds(song);
const mainAlbumId = props.trackGetters.getAlbumIds(track)[0];
const trackId = props.trackGetters.getId(track);
const tagIds = props.trackGetters.getTagIds(track);
const onClickArtist = () => {
history.push('/artist/' + mainArtistId);
@ -64,15 +64,15 @@ export default function SongTable(props: {
history.push('/album/' + mainAlbumId);
}
const onClickSong = () => {
history.push('/song/' + songId);
const onClickTrack = () => {
history.push('/track/' + trackId);
}
const onClickTag = (id: number, name: string) => {
history.push('/tag/' + id);
}
const tags = props.songGetters.getTagNames(song).map((tag: string[], i: number) => {
const tags = props.trackGetters.getTagNames(track).map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
@ -100,7 +100,7 @@ export default function SongTable(props: {
}
return <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={onClickAlbum}>{album}</TextCell>
<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 AudiotrackIcon from '@material-ui/icons/Audiotrack';
import LoyaltyIcon from '@material-ui/icons/Loyalty';
import SongWindow, { SongWindowReducer } from './song/SongWindow';
import TrackWindow, { TrackWindowReducer } from './track/TrackWindow';
import AlbumWindow, { AlbumWindowReducer } from './album/AlbumWindow';
import TagWindow, { TagWindowReducer } from './tag/TagWindow';
import { songGetters } from '../../lib/songGetters';
import { trackGetters } from '../../lib/trackGetters';
import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow';
import { RegisterWindowReducer } from './register/RegisterWindow';
import { LoginWindowReducer } from './login/LoginWindow';
@ -21,7 +21,7 @@ export enum WindowType {
Artist = "Artist",
Album = "Album",
Tag = "Tag",
Song = "Song",
Track = "Track",
ManageTags = "ManageTags",
Login = "Login",
Register = "Register",
@ -36,7 +36,7 @@ export const newWindowReducer = {
[WindowType.Query]: QueryWindowReducer,
[WindowType.Artist]: ArtistWindowReducer,
[WindowType.Album]: AlbumWindowReducer,
[WindowType.Song]: SongWindowReducer,
[WindowType.Track]: TrackWindowReducer,
[WindowType.Tag]: TagWindowReducer,
[WindowType.ManageTags]: ManageTagsWindowReducer,
[WindowType.Login]: LoginWindowReducer,
@ -59,8 +59,8 @@ export const newWindowState = {
id: 1,
metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsByArtist: null,
trackGetters: trackGetters,
tracksByArtist: null,
}
},
[WindowType.Album]: () => {
@ -68,11 +68,11 @@ export const newWindowState = {
id: 1,
metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsOnAlbum: null,
trackGetters: trackGetters,
tracksOnAlbum: null,
}
},
[WindowType.Song]: () => {
[WindowType.Track]: () => {
return {
id: 1,
metadata: null,
@ -84,8 +84,8 @@ export const newWindowState = {
id: 1,
metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsWithTag: null,
trackGetters: trackGetters,
tracksWithTag: null,
}
},
[WindowType.ManageTags]: () => {

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

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

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

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

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

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

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

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

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

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { AppBar, Box, Button, Dialog, DialogActions, Divider, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core";
import { SongMetadata } from "./SongWindow";
import { TrackMetadata } from "./TrackWindow";
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search';
@ -9,25 +9,25 @@ import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete';
import { $enum } from "ts-enum-util";
import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations';
import { IntegrationFeature, IntegrationSong } from '../../../lib/integration/Integration';
import { IntegrationFeature, IntegrationTrack } from '../../../lib/integration/Integration';
import { TabPanel } from '@material-ui/lab';
import { v1 } from 'uuid';
import { ExternalStore } from '../../../api';
import { IntegrationWith } from '../../../api/api';
let _ = require('lodash')
export function ProvideLinksWidget(props: {
providers: IntegrationState[],
metadata: SongMetadata,
store: ExternalStore,
metadata: TrackMetadata,
store: IntegrationWith,
onChange: (link: string | undefined) => void,
}) {
let defaultQuery = `${props.metadata.title}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.albums && ` ${props.metadata.albums[0].name}`}`;
let defaultQuery = `${props.metadata.name}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.album && ` ${props.metadata.album.name}`}`;
let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined
);
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 ?
props.providers[selectedProviderIdx] : undefined;
@ -63,14 +63,14 @@ export function ProvideLinksWidget(props: {
/>
<IconButton
onClick={() => {
selectedProvider?.integration.searchSong(query, 10)
.then((songs: IntegrationSong[]) => setResults(songs))
selectedProvider?.integration.searchTrack(query, 10)
.then((tracks: IntegrationTrack[]) => setResults(tracks))
}}
><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl>
<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}"
${result.artist && ` by ${result.artist.name}`}
${result.album && ` (${result.album.name})`}`;
@ -92,15 +92,15 @@ export function ProvideLinksWidget(props: {
}
export function ExternalLinksEditor(props: {
metadata: SongMetadata,
original: SongMetadata,
onChange: (v: SongMetadata) => void,
metadata: TrackMetadata,
original: TrackMetadata,
onChange: (v: TrackMetadata) => void,
}) {
let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations();
let getLinksSet = (metadata: SongMetadata) => {
return $enum(ExternalStore).getValues().reduce((prev: any, store: string) => {
let getLinksSet = (metadata: TrackMetadata) => {
return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => {
var maybeLink: string | null = null;
metadata.storeLinks && metadata.storeLinks.forEach((link: string) => {
if (whichStore(link) === store) {
@ -117,11 +117,11 @@ export function ExternalLinksEditor(props: {
let linksSet: Record<string, string | null> = getLinksSet(props.metadata);
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) ?
integrations.state.filter(
(iState: IntegrationState) => (
iState.integration.getFeatures().includes(IntegrationFeature.SearchSong) &&
iState.integration.getFeatures().includes(IntegrationFeature.SearchTrack) &&
iState.integration.providesStoreLink() === store
)
) : [];
@ -130,7 +130,7 @@ export function ExternalLinksEditor(props: {
<Box width="30%">
<List>
{$enum(ExternalStore).getValues().map((store: string, idx: number) => {
{$enum(IntegrationWith).getValues().map((store: string, idx: number) => {
let maybeLink = linksSet[store];
let color: string | undefined =
(linksSet[store] && !originalLinksSet[store]) ? "lightgreen" :
@ -190,19 +190,19 @@ export function ExternalLinksEditor(props: {
</Box >
}
export default function EditSongDialog(props: {
export default function EditTrackDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: SongMetadata) => void,
onSubmit: (v: TrackMetadata) => void,
id: number,
metadata: SongMetadata,
metadata: TrackMetadata,
}) {
enum EditSongTabs {
enum EditTrackTabs {
Details = 0,
ExternalLinks,
}
let [editingMetadata, setEditingMetadata] = useState<SongMetadata>(props.metadata);
let [editingMetadata, setEditingMetadata] = useState<TrackMetadata>(props.metadata);
return <Dialog
maxWidth="lg"
@ -217,7 +217,7 @@ export default function EditSongDialog(props: {
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: SongMetadata) => setEditingMetadata(v)}
onChange={(v: TrackMetadata) => setEditingMetadata(v)}
/>
<Divider />
{!_.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 PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../../api';
import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows';
import { ArtistMetadata } from '../artist/ArtistWindow';
import { AlbumMetadata } from '../album/AlbumWindow';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { querySongs } from '../../../lib/backend/queries';
import { queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import EditSongDialog from './EditSongDialog';
import EditTrackDialog from './EditTrackDialog';
import EditIcon from '@material-ui/icons/Edit';
import { modifySong } from '../../../lib/saveChanges';
import { modifyTrack } from '../../../lib/saveChanges';
export type SongMetadata = serverApi.SongDetails;
export type TrackMetadata = serverApi.TrackWithDetails;
export interface SongWindowState extends WindowState {
export interface TrackWindowState extends WindowState {
id: number,
metadata: SongMetadata | null,
metadata: TrackMetadata | null,
}
export enum SongWindowStateActions {
export enum TrackWindowStateActions {
SetMetadata = "SetMetadata",
Reload = "Reload",
}
export function SongWindowReducer(state: SongWindowState, action: any) {
export function TrackWindowReducer(state: TrackWindowState, action: any) {
switch (action.type) {
case SongWindowStateActions.SetMetadata:
case TrackWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case SongWindowStateActions.Reload:
case TrackWindowStateActions.Reload:
return { ...state, metadata: null }
default:
throw new Error("Unimplemented SongWindow state update.")
throw new Error("Unimplemented TrackWindow state update.")
}
}
export async function getSongMetadata(id: number) {
let response: any = await querySongs(
export async function getTrackMetadata(id: number) {
let response: any = await queryTracks(
{
a: QueryLeafBy.SongId,
a: QueryLeafBy.TrackId,
b: id,
leafOp: QueryLeafOp.Equals,
}, 0, 1, serverApi.QueryResponseType.Details
@ -49,37 +49,37 @@ export async function getSongMetadata(id: number) {
return response[0];
}
export default function SongWindow(props: {}) {
export default function TrackWindow(props: {}) {
const { id } = useParams<{ id: string }>();
const [state, dispatch] = useReducer(SongWindowReducer, {
const [state, dispatch] = useReducer(TrackWindowReducer, {
id: parseInt(id),
metadata: null,
});
return <SongWindowControlled state={state} dispatch={dispatch} />
return <TrackWindowControlled state={state} dispatch={dispatch} />
}
export function SongWindowControlled(props: {
state: SongWindowState,
export function TrackWindowControlled(props: {
state: TrackWindowState,
dispatch: (action: any) => void,
}) {
let { metadata, id: songId } = props.state;
let { metadata, id: trackId } = props.state;
let { dispatch } = props;
let [editing, setEditing] = useState<boolean>(false);
useEffect(() => {
if (metadata === null) {
getSongMetadata(songId)
.then((m: SongMetadata) => {
getTrackMetadata(trackId)
.then((m: TrackMetadata) => {
dispatch({
type: SongWindowStateActions.SetMetadata,
type: TrackWindowStateActions.SetMetadata,
value: m
});
})
}
}, [songId, dispatch, metadata]);
}, [trackId, dispatch, metadata]);
const title = <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) => {
return <Typography>
@ -87,11 +87,9 @@ export function SongWindowControlled(props: {
</Typography>
});
const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => {
return <Typography>
{album.name}
</Typography>
});
const album = metadata?.album && <Typography>
{metadata?.album.name}
</Typography>;
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
@ -134,7 +132,7 @@ export function SongWindowControlled(props: {
<Box display="flex" alignItems="center" m={0.5}>
<AlbumIcon />
<Box m={0.5}>
{albums}
{album}
</Box>
</Box>
</Box>
@ -150,16 +148,16 @@ export function SongWindowControlled(props: {
</Box>
</Box>}
</Box>
{metadata && <EditSongDialog
{metadata && <EditTrackDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.ModifySongRequest) => {
modifySong(songId, v)
onSubmit={(v: serverApi.PatchTrackRequest) => {
modifyTrack(trackId, v)
.then(() => dispatch({
type: SongWindowStateActions.Reload
type: TrackWindowStateActions.Reload
}))
}}
id={songId}
id={trackId}
metadata={metadata}
/>}
</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';
export async function getAlbum(id: number) {
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.AlbumDetailsEndpoint.replace(':id', `${id}`))
export async function getAlbum(id: number): Promise<GetAlbumResponse> {
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.GetAlbumEndpoint.replace(':id', `${id}`))
if (!response.ok) {
throw new Error("Response to album request not OK: " + JSON.stringify(response));
}

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

@ -1,15 +1,16 @@
import * as serverApi from '../../api';
import * as serverApi from '../../api/api';
import { PutIntegrationResponse } from '../../api/api';
import { useAuth } from '../useAuth';
import backendRequest from './request';
export async function createIntegration(details: serverApi.CreateIntegrationRequest) {
export async function createIntegration(details: serverApi.PostIntegrationRequest): Promise<serverApi.PostIntegrationResponse> {
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.PostIntegrationEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to integration creation not OK: " + JSON.stringify(response));
}
@ -17,7 +18,7 @@ export async function createIntegration(details: serverApi.CreateIntegrationRequ
return await response.json();
}
export async function modifyIntegration(id: number, details: serverApi.ModifyIntegrationRequest) {
export async function modifyIntegration(id: number, details: serverApi.PatchIntegrationRequest): Promise<serverApi.PatchIntegrationResponse> {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@ -25,16 +26,16 @@ export async function modifyIntegration(id: number, details: serverApi.ModifyInt
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()),
(process.env.REACT_APP_BACKEND || "") + serverApi.PatchIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration modification not OK: " + JSON.stringify(response));
throw new Error("Response to integration Patch not OK: " + JSON.stringify(response));
}
}
export async function deleteIntegration(id: number) {
export async function deleteIntegration(id: number): Promise<serverApi.DeleteIntegrationResponse> {
const requestOpts = {
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 = {
method: 'GET',
};

@ -1,41 +1,26 @@
import * as serverApi from '../../api';
import * as serverApi from '../../api/api';
import { QueryElem, toApiQuery } from '../query/Query';
import backendRequest from './request';
export async function queryItems(
types: serverApi.ItemType[],
types: serverApi.ResourceType[],
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<{
artists: serverApi.ArtistDetails[],
albums: serverApi.AlbumDetails[],
tags: serverApi.TagDetails[],
songs: serverApi.SongDetails[],
} | {
artists: number[],
albums: number[],
tags: number[],
songs: number[],
} | {
artists: number,
albums: number,
tags: number,
songs: number,
}> {
): Promise<serverApi.QueryResponse> {
console.log("Types:", types);
var q: serverApi.QueryRequest = {
query: query ? toApiQuery(query) : {},
offsetsLimits: {
artistOffset: (types.includes(serverApi.ItemType.Artist)) ? (offset || 0) : undefined,
artistLimit: (types.includes(serverApi.ItemType.Artist)) ? (limit || -1) : undefined,
albumOffset: (types.includes(serverApi.ItemType.Album)) ? (offset || 0) : undefined,
albumLimit: (types.includes(serverApi.ItemType.Album)) ? (limit || -1) : undefined,
songOffset: (types.includes(serverApi.ItemType.Song)) ? (offset || 0) : undefined,
songLimit: (types.includes(serverApi.ItemType.Song)) ? (limit || -1) : undefined,
tagOffset: (types.includes(serverApi.ItemType.Tag)) ? (offset || 0) : undefined,
tagLimit: (types.includes(serverApi.ItemType.Tag)) ? (limit || -1) : undefined,
artistOffset: (types.includes(serverApi.ResourceType.Artist)) ? (offset || 0) : undefined,
artistLimit: (types.includes(serverApi.ResourceType.Artist)) ? (limit || -1) : undefined,
albumOffset: (types.includes(serverApi.ResourceType.Album)) ? (offset || 0) : undefined,
albumLimit: (types.includes(serverApi.ResourceType.Album)) ? (limit || -1) : undefined,
trackOffset: (types.includes(serverApi.ResourceType.Track)) ? (offset || 0) : undefined,
trackLimit: (types.includes(serverApi.ResourceType.Track)) ? (limit || -1) : undefined,
tagOffset: (types.includes(serverApi.ResourceType.Tag)) ? (offset || 0) : undefined,
tagLimit: (types.includes(serverApi.ResourceType.Tag)) ? (limit || -1) : undefined,
},
ordering: {
orderBy: {
@ -66,36 +51,9 @@ export async function queryArtists(
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.ArtistDetails[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Artist], query, offset, limit, responseType);
): Promise<serverApi.ArtistWithId[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Artist], query, offset, limit, responseType);
return r.artists;
// var q: serverApi.QueryRequest = {
// query: query ? toApiQuery(query) : {},
// offsetsLimits: {
// artistOffset: offset,
// artistLimit: limit,
// },
// ordering: {
// orderBy: {
// type: serverApi.OrderByType.Name,
// },
// ascending: true,
// },
// responseType: responseType,
// };
// const requestOpts = {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(q),
// };
// return (async () => {
// const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
// let json: any = await response.json();
// return json.artists;
// })();
}
export async function queryAlbums(
@ -103,73 +61,19 @@ export async function queryAlbums(
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.AlbumDetails[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Album], query, offset, limit, responseType);
): Promise<serverApi.AlbumWithId[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Album], query, offset, limit, responseType);
return r.albums;
// var q: serverApi.QueryRequest = {
// query: query ? toApiQuery(query) : {},
// offsetsLimits: {
// albumOffset: offset,
// albumLimit: limit,
// },
// ordering: {
// orderBy: {
// type: serverApi.OrderByType.Name,
// },
// ascending: true,
// },
// responseType: responseType,
// };
// const requestOpts = {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(q),
// };
// return (async () => {
// const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
// let json: any = await response.json();
// return json.albums;
// })();
}
export async function querySongs(
export async function queryTracks(
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.SongDetails[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Song], query, offset, limit, responseType);
return r.songs;
// var q: serverApi.QueryRequest = {
// query: query ? toApiQuery(query) : {},
// offsetsLimits: {
// songOffset: offset,
// songLimit: limit,
// },
// ordering: {
// orderBy: {
// type: serverApi.OrderByType.Name,
// },
// ascending: true,
// },
// responseType: responseType,
// };
// const requestOpts = {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(q),
// };
// return (async () => {
// const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
// let json: any = await response.json();
// return json.songs;
// })();
): Promise<serverApi.TrackWithId[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Track], query, offset, limit, responseType);
return r.tracks;
}
export async function queryTags(
@ -177,54 +81,7 @@ export async function queryTags(
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.TagDetails[] | number[] | number> {
let r = await queryItems([serverApi.ItemType.Tag], query, offset, limit, responseType);
): Promise<serverApi.TagWithId[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Tag], query, offset, limit, responseType);
return r.tags;
// var q: serverApi.QueryRequest = {
// query: query ? toApiQuery(query) : {},
// offsetsLimits: {
// tagOffset: offset,
// tagLimit: limit,
// },
// ordering: {
// orderBy: {
// type: serverApi.OrderByType.Name,
// },
// ascending: true,
// },
// responseType: responseType,
// };
// const requestOpts = {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(q),
// };
// return (async () => {
// const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts);
// let json: any = await response.json();
// const tags = json.tags;
// // Organise the tags into a tree structure.
// // First, we put them in an indexed dict.
// const idxTags: Record<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';
export async function createTag(details: serverApi.CreateTagRequest) {
export async function createTag(details: serverApi.PostTagRequest) {
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.PostTagEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to tag creation not OK: " + JSON.stringify(response));
}
return await response.json();
}
export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) {
export async function modifyTag(id: number, details: serverApi.PatchTagRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@ -23,7 +23,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest)
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()),
(process.env.REACT_APP_BACKEND || "") + serverApi.PatchTagEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {

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

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

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

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

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

@ -1,42 +1,42 @@
import * as serverApi from '../api';
import * as serverApi from '../api/api';
import backendRequest from './backend/request';
export async function modifySong(id: number, change: serverApi.ModifySongRequest) {
export async function modifyTrack(id: number, change: serverApi.PatchTrackRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString());
const endpoint = serverApi.PatchTrackEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save song changes: " + response.statusText);
throw new Error("Failed to save track changes: " + response.statusText);
}
}
export async function modifyArtist(id: number, change: serverApi.ModifyArtistRequest) {
export async function modifyArtist(id: number, change: serverApi.PatchArtistRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString());
const endpoint = serverApi.PatchArtistEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save artist changes: " + response.statusText);
}
}
export async function modifyAlbum(id: number, change: serverApi.ModifyAlbumRequest) {
export async function modifyAlbum(id: number, change: serverApi.PatchAlbumRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString());
const endpoint = serverApi.PatchAlbumEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText);

@ -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 PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../api';
import * as serverApi from '../api/api';
export interface AuthUser {
id: number,

@ -1,16 +1,14 @@
const bodyParser = require('body-parser');
import * as api from '../client/src/api';
import * as api from '../client/src/api/api';
import Knex from 'knex';
import { Query } from './endpoints/Query';
import { PostArtist, PutArtist, GetArtist } from './endpoints/Artist';
import { PostAlbum, PutAlbum, GetAlbum } from './endpoints/Album';
import { PostSong, PutSong, GetSong } from './endpoints/Song';
import { PostTag, PutTag, GetTag, DeleteTag, MergeTag } from './endpoints/Tag';
import { PostIntegration, PutIntegration, GetIntegration, DeleteIntegration, ListIntegrations } from './endpoints/Integration';
import { RegisterUser } from './endpoints/RegisterUser';
import { queryEndpoints } from './endpoints/Query';
import { artistEndpoints } from './endpoints/Artist';
import { albumEndpoints } from './endpoints/Album';
import { trackEndpoints } from './endpoints/Track';
import { tagEndpoints } from './endpoints/Tag';
import { integrationEndpoints } from './endpoints/Integration';
import { userEndpoints } from './endpoints/User';
import * as endpointTypes from './endpoints/types';
import { sha512 } from 'js-sha512';
@ -24,10 +22,10 @@ const invokeHandler = (handler: endpointTypes.EndpointHandler, knex: Knex) => {
return async (req: any, res: any) => {
console.log("Incoming", req.method, " @ ", req.url);
await handler(req, res, knex)
.catch(endpointTypes.catchUnhandledErrors)
.catch(endpointTypes.handleErrorsInEndpoint)
.catch((_e: endpointTypes.EndpointError) => {
let e: endpointTypes.EndpointError = _e;
console.log("Error handling request: ", e.internalMessage);
console.log("Error handling request: ", e.message);
res.sendStatus(e.httpStatus);
})
console.log("Finished handling", req.method, "@", req.url);
@ -104,34 +102,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
// Set up integration proxies
app.use('/integrations', checkLogin(), createIntegrations(knex));
// Set up REST API endpoints
app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong));
app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(PutSong));
app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(GetSong));
app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(Query));
app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(PostArtist));
app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(PutArtist));
app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(GetArtist));
app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(PostAlbum));
app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(PutAlbum));
app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(GetAlbum));
app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(PostTag));
app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(PutTag));
app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(GetTag));
app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTag));
app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTag));
app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(PostIntegration));
app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(PutIntegration));
app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(GetIntegration));
app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegration));
app.get(apiBaseUrl + api.ListIntegrationsEndpoint, checkLogin(), _invoke(ListIntegrations));
app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUser));
// Set up auth endpoints
app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => {
res.status(200).send({ userId: req.user.id });
});
@ -139,6 +110,26 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
req.logout();
res.status(200).send();
});
// Set up other endpoints
[
albumEndpoints,
artistEndpoints,
tagEndpoints,
trackEndpoints,
integrationEndpoints,
userEndpoints,
queryEndpoints,
].forEach((endpoints: [string, string, boolean, endpointTypes.EndpointHandler][]) => {
endpoints.forEach((endpoint: [string, string, boolean, endpointTypes.EndpointHandler]) => {
let [url, method, authenticated, handler] = endpoint;
if (authenticated) {
app[method](apiBaseUrl + url, checkLogin(), _invoke(handler));
} else {
app[method](apiBaseUrl + url, _invoke(handler));
}
})
});
}
export { SetupApp }

@ -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 { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
import { AlbumWithDetails } from '../../client/src/api/api';
import { createAlbum, deleteAlbum, getAlbum, modifyAlbum } from '../db/Album';
import { GetArtist } from './Artist';
export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkAlbumDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
// Start transfers for songs, tags and artists.
// Also request the album itself.
const tagsPromise: Promise<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 maybeAlbum: api.GetAlbumResponse | null =
await getAlbum(req.params.id, userId, knex);
const artistsPromise = knex.select('artistId')
.from('artists_albums')
.where({ 'albumId': req.params.id })
.then((artists: any) => {
return artists.map((artist: any) => artist['artistId'])
})
.then((ids: number[]) => knex.select(['id', 'name', 'storeLinks'])
.from('artists')
.whereIn('id', ids));
const albumPromise = knex.select('name', 'storeLinks')
.from('albums')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((albums: any) => albums[0]);
// Wait for the requests to finish.
const [album, tags, songs, artists] =
await Promise.all([albumPromise, tagsPromise, songsPromise, artistsPromise]);
// Respond to the request.
if (album) {
const response: api.AlbumDetailsResponse = {
name: album['name'],
artists: artists,
tags: tags,
songs: songs,
storeLinks: asJson(album['storeLinks']),
};
await res.send(response);
if (maybeAlbum) {
await res.send(maybeAlbum);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e);
handleErrorsInEndpoint(e);
}
}
export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateAlbumRequest(req)) {
if (!api.checkPostAlbumRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PostAlbum request',
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateAlbumRequest = req.body;
const reqObject: api.PostAlbumRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Album ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('id')
.from('artists')
.where({ 'user': userId })
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Wait for the requests to finish.
var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);;
// Check that we found all artists and tags we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length)) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the album.
const albumId = (await trx('albums')
.insert({
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the artists via the linking table.
if (artists && artists.length) {
await trx('artists_albums').insert(
artists.map((artistId: number) => {
return {
artistId: artistId,
albumId: albumId,
}
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('albums_tags').insert(
tags.map((tagId: number) => {
return {
albumId: albumId,
tagId: tagId,
}
})
)
}
// Respond to the request.
const responseObject: api.CreateSongResponse = {
id: albumId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
try {
let id = await createAlbum(userId, reqObject, knex);
res.status(200).send(id);
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyAlbumRequest(req)) {
if (!api.checkPutAlbumRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PutAlbum request',
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyAlbumRequest = req.body;
const reqObject: api.PutAlbumRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Album ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the album itself.
const albumPromise = trx.select('id')
.from('albums')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined);
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('artistId')
.from('artists_albums')
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['artistId'])) :
(async () => { return undefined })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('albums_tags')
.whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return undefined })();
// Wait for the requests to finish.
var [album, artists, tags] = await Promise.all([albumPromise, artistIdsPromise, tagIdsPromise]);;
// Check that we found all objects we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length) ||
!album) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for ModifyAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the album.
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifyAlbumPromise = trx('albums')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update(update)
// Remove unlinked artists.
// TODO: test this!
const removeUnlinkedArtists = artists ? trx('artists_albums')
.where({ 'albumId': req.params.id })
.whereNotIn('artistId', reqObject.artistIds || [])
.delete() : undefined;
try {
modifyAlbum(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
// Remove unlinked tags.
// TODO: test this!
const removeUnlinkedTags = tags ? trx('albums_tags')
.where({ 'albumId': req.params.id })
.whereNotIn('tagId', reqObject.tagIds || [])
.delete() : undefined;
export const PatchAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchAlbumRequest(req)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PatchAlbum request',
httpStatus: 400
};
throw e;
}
const reqObject: api.PatchAlbumRequest = req.body;
const { id: userId } = req.user;
// Link new artists.
// TODO: test this!
const addArtists = artists ? trx('artists_albums')
.where({ 'albumId': req.params.id })
.then((as: any) => as.map((a: any) => a['artistId']))
.then((doneArtistIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = artists.filter((id: number) => {
return !doneArtistIds.includes(id);
});
const insertObjects = toLink.map((artistId: number) => {
return {
artistId: artistId,
albumId: req.params.id,
}
})
console.log("User ", userId, ": Patch Album ", reqObject);
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('artists_albums').insert(obj)
)
);
}) : undefined;
try {
modifyAlbum(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
// Link new tags.
// TODO: test this!
const addTags = tags ? trx('albums_tags')
.where({ 'albumId': req.params.id })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
albumId: req.params.id,
}
})
export const DeleteAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const { id: userId } = req.user;
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('albums_tags').insert(obj)
)
);
}) : undefined;
console.log("User ", userId, ": Delete Album ", req.params.id);
// Wait for all operations to finish.
await Promise.all([
modifyAlbumPromise,
removeUnlinkedArtists,
removeUnlinkedTags,
addArtists,
addTags
]);
try {
await deleteAlbum(userId, req.params.id, knex);
res.status(200).send();
// Respond to the request.
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
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 { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
import { createArtist, deleteArtist, getArtist, modifyArtist } from '../db/Artist';
export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkArtistDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const tags: api.TagDetailsResponseWithId[] = await knex.select('tagId')
.from('artists_tags')
.where({ 'artistId': req.params.id })
.then((ts: any) => {
return Array.from(new Set(
ts.map((tag: any) => tag['tagId'])
)) as number[];
})
.then((ids: number[]) => knex.select(['id', 'name', 'parentId'])
.from('tags')
.whereIn('id', ids));
const results = await knex.select(['id', 'name', 'storeLinks'])
.from('artists')
.where({ 'user': userId })
.where({ 'id': req.params.id });
if (results[0]) {
const response: api.ArtistDetailsResponse = {
name: results[0].name,
tags: tags,
storeLinks: asJson(results[0].storeLinks),
}
await res.send(response);
} else {
await res.status(404).send({});
}
let artist = await getArtist(req.params.id, userId, knex);
await res.status(200).send(artist);
} catch (e) {
catchUnhandledErrors(e)
handleErrorsInEndpoint(e)
}
}
export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateArtistRequest(req)) {
if (!api.checkPostArtistRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostArtist request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PostArtist request',
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateArtistRequest = req.body;
const reqObject: api.PostArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create artist ", reqObject)
await knex.transaction(async (trx) => {
try {
// Retrieve tag instances to link the artist to.
const tags: number[] = reqObject.tagIds ?
Array.from(new Set(
(await trx.select('id').from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds))
.map((tag: any) => tag['id'])
))
: [];
if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) {
const e: EndpointError = {
internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the artist.
const artistId = (await trx('artists')
.insert({
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('artists_tags').insert(
tags.map((tagId: number) => {
return {
artistId: artistId,
tagId: tagId,
}
})
)
}
const responseObject: api.CreateSongResponse = {
id: artistId
};
await res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
});
try {
const id = await createArtist(userId, reqObject, knex);
await res.status(200).send({ id: id });
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyArtistRequest(req)) {
if (!api.checkPutArtistRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutArtist request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PutArtist request',
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyArtistRequest = req.body;
const reqObject: api.PutArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Artist ", reqObject);
await knex.transaction(async (trx) => {
try {
const artistId = parseInt(req.params.id);
// Start retrieving the artist itself.
const artistPromise = trx.select('id')
.from('artists')
.where({ 'user': userId })
.where({ id: artistId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('artists_tags')
.whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return undefined })();
// Wait for the requests to finish.
var [artist, tags] = await Promise.all([artistPromise, tagIdsPromise]);;
// Check that we found all objects we need.
if ((reqObject.tagIds && tags.length !== reqObject.tagIds.length) ||
!artist) {
const e: EndpointError = {
internalMessage: 'Not all artists and/or tags exist for ModifyArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the artist.
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifyArtistPromise = trx('artists')
.where({ 'user': userId })
.where({ 'id': artistId })
.update(update)
// Remove unlinked tags.
// TODO: test this!
const removeUnlinkedTags = tags ?
trx('artists_tags')
.where({ 'artistId': artistId })
.whereNotIn('tagId', reqObject.tagIds || [])
.delete() :
undefined;
// Link new tags.
// TODO: test this!
const addTags = tags ? trx('artists_tags')
.where({ 'artistId': artistId })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
artistId: artistId,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('artists_tags').insert(obj)
)
);
}) : undefined;
// Wait for all operations to finish.
await Promise.all([
modifyArtistPromise,
removeUnlinkedTags,
addTags
]);
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
try {
await modifyArtist(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PatchArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchArtistRequest(req)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PatchArtist request',
httpStatus: 400
};
throw e;
}
const reqObject: api.PatchArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Patch Artist ", reqObject);
try {
await modifyArtist(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const DeleteArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const { id: userId } = req.user;
console.log("User ", userId, ": Delete Artist ", req.params.id);
try {
await deleteArtist(userId, req.params.id, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const artistEndpoints: [ string, string, boolean, EndpointHandler ][] = [
[ api.PostArtistEndpoint, 'post', true, PostArtist ],
[ api.GetArtistEndpoint, 'get', true, GetArtist ],
[ api.PutArtistEndpoint, 'put', true, PutArtist ],
[ api.PatchArtistEndpoint, 'patch', true, PatchArtist ],
[ api.DeleteArtistEndpoint, 'delete', true, DeleteArtist ],
];

@ -1,206 +1,122 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
import { createIntegration, deleteIntegration, getIntegration, listIntegrations, modifyIntegration } from '../db/Integration';
import { IntegrationDataWithId } from '../../client/src/api/api';
export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateIntegrationRequest(req)) {
if (!api.checkPostIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostIntegration request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PostIntegration request',
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateIntegrationRequest = req.body;
const reqObject: api.PostIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Integration ", reqObject);
await knex.transaction(async (trx) => {
try {
// Create the new integration.
var integration: any = {
name: reqObject.name,
user: userId,
type: reqObject.type,
details: JSON.stringify(reqObject.details),
secretDetails: JSON.stringify(reqObject.secretDetails),
}
const integrationId = (await trx('integrations')
.insert(integration)
.returning('id') // Needed for Postgres
)[0];
// Respond to the request.
const responseObject: api.CreateIntegrationResponse = {
id: integrationId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkIntegrationDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetIntegration request: ' + JSON.stringify(req.body),
httpStatus: 400
try {
let id = await createIntegration(userId, reqObject, knex);
const responseObject: api.PostIntegrationResponse = {
id: id
};
throw e;
}
res.status(200).send(responseObject);
const { id: userId } = req.user;
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
try {
const integration = (await knex.select(['id', 'name', 'type', 'details'])
.from('integrations')
.where({ 'user': userId, 'id': req.params.id }))[0];
if (integration) {
const response: api.IntegrationDetailsResponse = {
name: integration.name,
type: integration.type,
details: asJson(integration.details),
}
await res.send(response);
} else {
await res.status(404).send({});
}
let integration = await getIntegration(req.user.id, req.params.id, knex);
res.status(200).send(integration);
} catch (e) {
catchUnhandledErrors(e)
handleErrorsInEndpoint(e)
}
}
export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkIntegrationDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ListIntegrations request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
const { id: userId } = req.user;
console.log("List integrations");
try {
const integrations: IntegrationDataWithId[] = await listIntegrations(req.user.id, knex);
console.log("Found integrations:", integrations);
await res.status(200).send(integrations);
} catch (e) {
handleErrorsInEndpoint(e)
}
}
export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const { id: userId } = req.user;
console.log("List integrations");
console.log("User ", userId, ": Delete Integration ", req.params.id);
try {
const integrations: api.ListIntegrationsResponse = (
await knex.select(['id', 'name', 'type', 'details'])
.from('integrations')
.where({ user: userId })
).map((object: any) => {
return {
id: object.id,
name: object.name,
type: object.type,
details: asJson(object.details),
}
})
await deleteIntegration(userId, req.params.id, knex);
res.status(200).send();
console.log("Found integrations:", integrations);
await res.send(integrations);
} catch (e) {
catchUnhandledErrors(e)
handleErrorsInEndpoint(e);
}
}
export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteIntegrationRequest(req)) {
export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PutIntegration request',
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteIntegrationRequest = req.body;
const reqObject: api.PutIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Delete Integration ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the integration itself.
const integrationId = await trx.select('id')
.from('integrations')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Check that we found all objects we need.
if (!integrationId) {
const e: EndpointError = {
internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body),
httpStatus: 404
};
throw e;
}
// Delete the integration.
await trx('integrations')
.where({ 'user': userId, 'id': integrationId })
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
console.log("User ", userId, ": Put Integration ", reqObject);
try {
await modifyIntegration(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyIntegrationRequest(req)) {
export const PatchIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutIntegration request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PatchIntegration request',
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyIntegrationRequest = req.body;
const reqObject: api.PatchIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Integration ", reqObject);
console.log("User ", userId, ": Patch Integration ", reqObject);
try {
await modifyIntegration(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
await knex.transaction(async (trx) => {
try {
// Start retrieving the integration.
const integrationId = await trx.select('id')
.from('integrations')
.where({ 'user': userId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Check that we found all objects we need.
if (!integrationId) {
const e: EndpointError = {
internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body),
httpStatus: 404
};
throw e;
}
// Modify the integration.
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); }
if ("type" in reqObject) { update["type"] = reqObject.type; }
if ("secretDetails" in reqObject) { update["secretDetails"] = JSON.stringify(reqObject.details); }
await trx('integrations')
.where({ 'user': userId, 'id': req.params.id })
.update(update)
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
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 { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
import { toApiArtist, toApiTag, toApiAlbum, toApiSong } from '../lib/dbToApi';
enum ObjectType {
Song = 0,
Artist,
Tag,
Album,
}
// To keep track of which database objects are needed to filter on
// certain properties.
const propertyObjects: Record<api.QueryElemProperty, 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);
}
import { doQuery } from '../db/Query';
export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkQueryRequest(req.body)) {
const e: EndpointError = {
internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid Query request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
@ -286,164 +18,13 @@ export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) =>
console.log("User ", userId, ": Query ", reqObject);
try {
const songLimit = reqObject.offsetsLimits.songLimit;
const songOffset = reqObject.offsetsLimits.songOffset;
const tagLimit = reqObject.offsetsLimits.tagLimit;
const tagOffset = reqObject.offsetsLimits.tagOffset;
const artistLimit = reqObject.offsetsLimits.artistLimit;
const artistOffset = reqObject.offsetsLimits.artistOffset;
const albumLimit = reqObject.offsetsLimits.albumLimit;
const albumOffset = reqObject.offsetsLimits.albumOffset;
const artistsPromise: Promise<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);
let r = doQuery(userId, reqObject, knex);
res.status(200).send(r);
} 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 { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex';
import { createTag, deleteTag, getTag, mergeTag, modifyTag } from '../db/Tag';
export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateTagRequest(req)) {
if (!api.checkPostTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostTag request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PostTag request',
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateTagRequest = req.body;
const reqObject: api.PostTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// If applicable, retrieve the parent tag.
const maybeParent: number | undefined =
reqObject.parentId ?
(await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': reqObject.parentId }))[0]['id'] :
undefined;
// Check if the parent was found, if applicable.
if (reqObject.parentId && maybeParent !== reqObject.parentId) {
const e: EndpointError = {
internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the new tag.
var tag: any = {
name: reqObject.name,
user: userId,
};
if (maybeParent) {
tag['parentId'] = maybeParent;
}
const tagId = (await trx('tags')
.insert(tag)
.returning('id') // Needed for Postgres
)[0];
// Respond to the request.
const responseObject: api.CreateTagResponse = {
id: tagId
};
res.status(200).send(responseObject);
try {
// Respond to the request.
const responseObject: api.PostTagResponse = {
id: await createTag(userId, reqObject, knex)
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
} catch (e) {
handleErrorsInEndpoint(e);
}
}
async function getChildrenRecursive(id: number, userId: number, trx: any) {
const directChildren = (await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'parentId': id })).map((r: any) => r.id);
const indirectChildrenPromises = directChildren.map(
(child: number) => getChildrenRecursive(child, userId, trx)
);
const indirectChildrenNested = await Promise.all(indirectChildrenPromises);
const indirectChildren = indirectChildrenNested.flat();
return [
...directChildren,
...indirectChildren,
]
}
export const DeleteTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Delete Tag ", req.params.id);
console.log("User ", userId, ": Delete Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving any child tags.
const childTagsPromise =
getChildrenRecursive(req.params.id, userId, trx);
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, children] = await Promise.all([tagPromise, childTagsPromise]);
// Merge all IDs.
const toDelete = [ tag, ...children ];
// Check that we found all objects we need.
if (!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Delete the tag and its children.
await trx('tags')
.where({ 'user': userId })
.whereIn('id', toDelete)
.del();
try {
// Respond to the request.
res.status(200).send();
deleteTag(userId, req.params.id, knex);
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkTagDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const results = await knex.select(['id', 'name', 'parentId'])
.from('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id });
if (results[0]) {
const response: api.TagDetailsResponse = {
name: results[0].name,
parentId: results[0].parentId || undefined,
}
await res.send(response);
} else {
await res.status(404).send({});
}
let tag = await getTag(req.params.id, userId, knex);
await res.status(200).send(tag);
} catch (e) {
catchUnhandledErrors(e)
handleErrorsInEndpoint(e)
}
}
export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyTagRequest(req)) {
if (!api.checkPutTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutTag request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PutTag request',
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyTagRequest = req.body;
const reqObject: api.PutTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the parent tag.
const parentTagPromise = reqObject.parentId ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': reqObject.parentId })
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return [] })();
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);;
// Check that we found all objects we need.
if ((reqObject.parentId && !parent) ||
!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update({
name: reqObject.name,
parentId: reqObject.parentId || null,
})
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
try {
await modifyTag(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkMergeTagRequest(req)) {
export const PatchTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body),
name: "EndpointError",
message: 'Invalid PatchTag request',
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const reqObject: api.PatchTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Merge Tag ", reqObject);
const fromId = req.params.id;
const toId = req.params.toId;
await knex.transaction(async (trx) => {
try {
// Start retrieving the "from" tag.
const fromTagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: fromId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving the "to" tag.
const toTagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: toId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
console.log("User ", userId, ": Patch Tag ", reqObject);
// Wait for the requests to finish.
var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]);
try {
await modifyTag(userId, req.params.id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
// Check that we found all objects we need.
if (!fromTag || !toTag) {
const e: EndpointError = {
internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const { id: userId } = req.user;
// Assign new tag ID to any objects referencing the to-be-merged tag.
const cPromise = trx('tags')
.where({ 'user': userId })
.where({ 'parentId': fromId })
.update({ 'parentId': toId });
const sPromise = trx('songs_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const arPromise = trx('artists_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const alPromise = trx('albums_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
await Promise.all([sPromise, arPromise, alPromise, cPromise]);
console.log("User ", userId, ": Merge Tag ", req.params.id, req.params.toId);
const fromId = req.params.id;
const toId = req.params.toId;
// Delete the original tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': fromId })
.del();
try {
mergeTag(userId, fromId, toId, knex);
res.status(200).send();
// Respond to the request.
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
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 interface EndpointError {
internalMessage: String;
export interface EndpointError extends Error {
name: "EndpointError",
message: string;
httpStatus: Number;
}
export enum DBErrorKind {
Unknown = "Unknown",
ResourceNotFound = "ResourceNotFound",
ResourceConflict = "ResourceConflict",
}
export interface DBError extends Error {
name: "DBError",
kind: DBErrorKind,
message: string,
}
export function isEndpointError(obj: any): obj is EndpointError {
return obj.internalMessage !== undefined &&
obj.httpStatus !== undefined;
return obj.name === "EndpointError";
}
export const catchUnhandledErrors = (_e: any) => {
if (isEndpointError(_e)) {
// Rethrow
throw _e;
export function isDBError(obj: any): obj is DBError {
return obj.name === "DBError";
}
export function toEndpointError(e: Error): EndpointError {
if (isEndpointError(e)) { return e; }
else if (isDBError(e) && e.kind === DBErrorKind.ResourceNotFound) {
return {
name: "EndpointError",
message: e.message,
httpStatus: 404,
}
}
// This is an unhandled error, make an internal server error out of it.
const e: EndpointError = {
internalMessage: _e,
httpStatus: 500
return {
name: "EndpointError",
message: e.message,
httpStatus: 500,
}
throw e;
}
export const handleErrorsInEndpoint = (_e: any) => {
throw toEndpointError(_e);
}

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

@ -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> {
// Songs table.
// tracks table.
await knex.schema.createTable(
'songs',
'tracks',
(table: any) => {
table.increments('id');
table.string('title');
table.json('storeLinks')
table.string('name');
table.string('storeLinks')
table.integer('user').unsigned().notNullable().defaultTo(1);
table.integer('album').unsigned().defaultTo(null);
}
)
@ -18,7 +20,8 @@ export async function up(knex: Knex): Promise<void> {
(table: any) => {
table.increments('id');
table.string('name');
table.json('storeLinks');
table.string('storeLinks');
table.integer('user').unsigned().notNullable().defaultTo(1);
}
)
@ -28,7 +31,8 @@ export async function up(knex: Knex): Promise<void> {
(table: any) => {
table.increments('id');
table.string('name');
table.json('storeLinks');
table.string('storeLinks');
table.integer('user').unsigned().notNullable().defaultTo(1);
}
)
@ -39,36 +43,53 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id');
table.string('name');
table.integer('parentId');
table.integer('user').unsigned().notNullable().defaultTo(1);
}
)
// Songs <-> Artists
// Users table.
await knex.schema.createTable(
'songs_artists',
'users',
(table: any) => {
table.increments('id');
table.integer('songId');
table.integer('artistId');
table.string('email');
table.string('passwordHash')
}
)
// Songs <-> Albums
// Integrations table.
await knex.schema.createTable(
'songs_albums',
'integrations',
(table: any) => {
table.increments('id');
table.integer('songId');
table.integer('albumId');
table.integer('user').unsigned().notNullable().defaultTo(1);
table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user.
table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify)
table.string('details'); // Stores anything that might be needed for the integration to work.
table.string('secretDetails'); // Stores anything that might be needed for the integration to work and which
// should never leave the server.
}
)
// tracks <-> Artists
await knex.schema.createTable(
'tracks_artists',
(table: any) => {
table.increments('id');
table.integer('trackId');
table.integer('artistId');
table.unique(['trackId', 'artistId'])
}
)
// Songs <-> Tags
// tracks <-> Tags
await knex.schema.createTable(
'songs_tags',
'tracks_tags',
(table: any) => {
table.increments('id');
table.integer('songId');
table.integer('trackId');
table.integer('tagId');
table.unique(['trackId', 'tagId'])
}
)
@ -79,6 +100,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id');
table.integer('artistId');
table.integer('tagId');
table.unique(['artistId', 'tagId'])
}
)
@ -89,6 +111,7 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id');
table.integer('tagId');
table.integer('albumId');
table.unique(['albumId', 'tagId'])
}
)
@ -99,21 +122,24 @@ export async function up(knex: Knex): Promise<void> {
table.increments('id');
table.integer('artistId');
table.integer('albumId');
table.unique(['artistId', 'albumId'])
}
)
}
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('albums');
await knex.schema.dropTable('tags');
await knex.schema.dropTable('songs_artists');
await knex.schema.dropTable('songs_albums');
await knex.schema.dropTable('songs_tags');
await knex.schema.dropTable('tracks_artists');
await knex.schema.dropTable('tracks_albums');
await knex.schema.dropTable('tracks_tags');
await knex.schema.dropTable('artists_tags');
await knex.schema.dropTable('albums_tags');
await knex.schema.dropTable('artists_albums');
await knex.schema.dropTable('users');
await knex.schema.dropTable('integrations');
}

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

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

Loading…
Cancel
Save