Compare commits

..

1 Commits

Author SHA1 Message Date
Sander Vocke a183252b88 Install cypress and a dummy test. 5 years ago
  1. 37
      .vscode/launch.json
  2. 17
      KNOWN_ISSUES
  3. 1
      client/cypress.json
  4. 5
      client/cypress/fixtures/example.json
  5. 5
      client/cypress/integration/appbar.tsx
  6. 21
      client/cypress/plugins/index.js
  7. 25
      client/cypress/support/commands.js
  8. 20
      client/cypress/support/index.js
  9. 2394
      client/package-lock.json
  10. 17
      client/package.json
  11. 14
      client/src/App.tsx
  12. 305
      client/src/api.ts
  13. 14
      client/src/api/api.ts
  14. 42
      client/src/api/endpoints/auth.ts
  15. 58
      client/src/api/endpoints/data.ts
  16. 122
      client/src/api/endpoints/query.ts
  17. 184
      client/src/api/endpoints/resources.ts
  18. 262
      client/src/api/types/resources.ts
  19. 1
      client/src/assets/spotify_icon.svg
  20. 16
      client/src/assets/youtubemusic_icon.svg
  21. 232
      client/src/components/MainWindow.tsx
  22. 32
      client/src/components/appbar/AddTabMenu.tsx
  23. 143
      client/src/components/appbar/AppBar.tsx
  24. 13
      client/src/components/common/DiscardChangesButton.tsx
  25. 118
      client/src/components/common/EditItemDialog.tsx
  26. 93
      client/src/components/common/EditableText.tsx
  27. 222
      client/src/components/common/ExternalLinksEditor.tsx
  28. 31
      client/src/components/common/FileUploadButton.tsx
  29. 23
      client/src/components/common/MenuEditText.tsx
  30. 30
      client/src/components/common/StoreLinkIcon.tsx
  31. 99
      client/src/components/querybuilder/QBAddElemMenu.tsx
  32. 93
      client/src/components/querybuilder/QBLeafElem.tsx
  33. 24
      client/src/components/querybuilder/QBNodeElem.tsx
  34. 1
      client/src/components/querybuilder/QBPlaceholder.tsx
  35. 24
      client/src/components/querybuilder/QBSelectWithRequest.tsx
  36. 15
      client/src/components/querybuilder/QueryBuilder.tsx
  37. 369
      client/src/components/tables/ResultsTable.tsx
  38. 231
      client/src/components/windows/AlbumWindow.tsx
  39. 231
      client/src/components/windows/ArtistWindow.tsx
  40. 146
      client/src/components/windows/QueryWindow.tsx
  41. 203
      client/src/components/windows/SongWindow.tsx
  42. 258
      client/src/components/windows/TagWindow.tsx
  43. 84
      client/src/components/windows/Windows.tsx
  44. 204
      client/src/components/windows/album/AlbumWindow.tsx
  45. 209
      client/src/components/windows/artist/ArtistWindow.tsx
  46. 131
      client/src/components/windows/login/LoginWindow.tsx
  47. 69
      client/src/components/windows/manage/ManageWindow.tsx
  48. 124
      client/src/components/windows/manage_data/ManageData.tsx
  49. 436
      client/src/components/windows/manage_links/BatchLinkDialog.tsx
  50. 134
      client/src/components/windows/manage_links/LinksStatusWidget.tsx
  51. 81
      client/src/components/windows/manage_links/ManageLinksWindow.tsx
  52. 97
      client/src/components/windows/manage_tags/ManageTagMenu.tsx
  53. 505
      client/src/components/windows/manage_tags/ManageTagsWindow.tsx
  54. 54
      client/src/components/windows/manage_tags/NewTagMenu.tsx
  55. 148
      client/src/components/windows/manage_tags/TagChange.tsx
  56. 290
      client/src/components/windows/query/QueryWindow.tsx
  57. 137
      client/src/components/windows/register/RegisterWindow.tsx
  58. 359
      client/src/components/windows/settings/IntegrationSettings.tsx
  59. 59
      client/src/components/windows/settings/SettingsWindow.tsx
  60. 204
      client/src/components/windows/tag/TagWindow.tsx
  61. 175
      client/src/components/windows/track/TrackWindow.tsx
  62. 14
      client/src/lib/backend/albums.tsx
  63. 14
      client/src/lib/backend/artists.tsx
  64. 32
      client/src/lib/backend/data.tsx
  65. 68
      client/src/lib/backend/integrations.tsx
  66. 112
      client/src/lib/backend/queries.tsx
  67. 34
      client/src/lib/backend/request.tsx
  68. 62
      client/src/lib/backend/tags.tsx
  69. 13
      client/src/lib/backend/tracks.tsx
  70. 61
      client/src/lib/integration/Integration.tsx
  71. 112
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  72. 178
      client/src/lib/integration/useIntegrations.tsx
  73. 310
      client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx
  74. 154
      client/src/lib/query/Getters.tsx
  75. 177
      client/src/lib/query/Query.tsx
  76. 43
      client/src/lib/saveChanges.tsx
  77. 28
      client/src/lib/songGetters.tsx
  78. 134
      client/src/lib/useAuth.tsx
  79. 14
      client/src/setupProxy.js
  80. 20253
      data/20201204_initial_gpm_data.mudbase.json
  81. 73
      package-lock.json
  82. 3
      package.json
  83. 2
      scripts/gpm_retrieve/.gitignore
  84. 76725
      scripts/gpm_retrieve/file.txt
  85. 176
      scripts/gpm_retrieve/gpm_retrieve.py
  86. 3
      scripts/gpm_retrieve/requirements.txt
  87. 143
      server/app.ts
  88. 359
      server/db/Album.ts
  89. 376
      server/db/Artist.ts
  90. 211
      server/db/Data.ts
  91. 107
      server/db/Integration.ts
  92. 513
      server/db/Query.ts
  93. 307
      server/db/Tag.ts
  94. 346
      server/db/Track.ts
  95. 35
      server/db/User.ts
  96. 10
      server/db/common.ts
  97. 116
      server/endpoints/Album.ts
  98. 59
      server/endpoints/AlbumDetailsEndpointHandler.ts
  99. 112
      server/endpoints/Artist.ts
  100. 35
      server/endpoints/ArtistDetailsEndpointHandler.ts
  101. Some files were not shown because too many files have changed in this diff Show More

@ -1,37 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Jasmine Tests with SQLite",
"env": {
"MUDBASE_DB_CONFIG": "{\"client\": \"sqlite3\", \"connection\": \":memory:\"}",
"TEST_RANDOM_SEED": "0.92820"
},
"program": "${workspaceFolder}/server/node_modules/jasmine-ts/lib/index",
"args": [
"--config=test/jasmine.json",
],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/server",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Development Server with SQLite @ dev.sqlite3",
"env": {
"API": "/api",
},
"program": "node_modules/.bin/nodemon",
"args": [ "server.ts" ],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/server",
"internalConsoleOptions": "neverOpen"
}
]
}

@ -1,17 +0,0 @@
last updated: 0a9bec1c874a9b62000b4156ae75e23991121ed0
- Youtube web scraper integration broken
- Spotify integration only finds artists, not albums or tracks
- Tag management shows only top-level tags
- (Maybe) patch requests broken?
- Checked and fixed track
- Lots of front-end typescript warnings
- When not logged in, an exception may occur trying to visit a page
instead of redirecting properly
- Google Play Music still listed although the service has been
terminated by Google
- during batch linking, linking dialog closes when clicking outside.
this shouldn't happen.
- during batch linking, if the page is left or the dialog closes,
the jobs are still continuing to execute. This shouldn't happen.
- no way to exit the edit dialog

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

@ -0,0 +1,5 @@
describe('My First Test', () => {
it('Does not do much!', () => {
expect(true).to.equal(true)
})
})

@ -0,0 +1,21 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

File diff suppressed because it is too large Load Diff

@ -15,9 +15,7 @@
"@types/react-dom": "^16.9.0",
"@types/react-router": "^5.1.8",
"@types/react-router-dom": "^5.1.5",
"@types/tiny-async-pool": "^1.0.0",
"@types/uuid": "^8.3.0",
"http-proxy-middleware": "^1.0.6",
"cypress": "^5.3.0",
"jsurl": "^0.1.5",
"lodash": "^4.17.20",
"material-table": "^1.69.0",
@ -26,16 +24,12 @@
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.13.1",
"react-error-boundary": "^3.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.3",
"tiny-async-pool": "^1.2.0",
"ts-enum-util": "^4.0.2",
"typescript": "~3.7.2",
"uuid": "^8.3.0"
"react-scripts": "3.4.1",
"typescript": "~3.7.2"
},
"scripts": {
"dev": "REACT_APP_BACKEND='/api' BROWSER=none react-scripts start",
"dev": "BROWSER=none react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
@ -54,5 +48,6 @@
"last 1 firefox version",
"last 1 safari version"
]
}
},
"proxy": "http://localhost:5000/"
}

@ -3,15 +3,25 @@ import React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import {
HashRouter as Router,
Switch,
Route
} from "react-router-dom";
import MainWindow from './components/MainWindow';
import { ProvideAuth } from './lib/useAuth';
import { ProvideIntegrations } from './lib/integration/useIntegrations';
function App() {
return (
<Router>
<DndProvider backend={HTML5Backend}>
<Switch>
<Route path="/">
<MainWindow/>
</Route>
</Switch>
</DndProvider>
</Router>
);
}

@ -0,0 +1,305 @@
// 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",
}
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",
}
export enum OrderByType {
Name = 0,
}
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,
}
export interface QueryResponse {
songs: SongDetails[],
artists: ArtistDetails[],
tags: TagDetails[],
albums: AlbumDetails[],
}
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
&& checkQueryElem(req.query);
}
// Get song details (GET).
export const SongDetailsEndpoint = '/song/:id';
export interface SongDetailsRequest { }
export interface SongDetailsResponse {
title: string,
storeLinks: string[],
artistIds: number[],
albumIds: number[],
tagIds: number[],
}
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,
tagIds: number[],
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;
tagIds: number[];
artistIds: number[];
songIds: number[];
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;
}

@ -1,14 +0,0 @@
// 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/data';
export * from './endpoints/resources';
export * from './endpoints/query';

@ -1,42 +0,0 @@
// 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";

@ -1,58 +0,0 @@
// 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.
import { Album, Id, AlbumRefs, Artist, ArtistRefs, Tag, TagParentId, Track, TrackRefs, isTrackRefs, isAlbumRefs, isArtistRefs, isTagParentId } from "../types/resources";
// The import/export DB format is just a set of lists of objects.
// Each object has an ID and references others by ID.
// Any object referenced by ID also has a reverse reference.
// In other words, if A references B, B must also reference A.
export interface DBDataFormat {
tracks: (Track & Id & TrackRefs)[],
albums: (Album & Id & AlbumRefs)[],
artists: (Artist & Id & ArtistRefs)[],
tags: (Tag & Id & TagParentId)[],
}
// Get a full export of a user's database (GET).
export const DBExportEndpoint = "/export";
export type DBExportResponse = DBDataFormat;
// Fully replace the user's database by an import (POST).
export const DBImportEndpoint = "/import";
export type DBImportRequest = DBDataFormat;
export interface IDMappings {
tracks: Record<number, number>,
albums: Record<number, number>,
artists: Record<number, number>,
tags: Record<number, number>,
}
export type DBImportResponse = IDMappings; // Returns the IDs mapped during import.
export const checkDBImportRequest: (v: any) => boolean = (v: any) => {
return 'tracks' in v &&
'albums' in v &&
'artists' in v &&
'tags' in v &&
v.tracks.reduce((prev: boolean, cur: any) => {
return prev && isTrackRefs(cur);
}, true) &&
v.albums.reduce((prev: boolean, cur: any) => {
return prev && isAlbumRefs(cur);
}, true) &&
v.artists.reduce((prev: boolean, cur: any) => {
return prev && isArtistRefs(cur);
}, true) &&
v.tags.reduce((prev: boolean, cur: any) => {
return prev && isTagParentId(cur);
}, true);
}
// Wipe this user's database (POST).
export const DBWipeEndpoint = "/wipe";
export type DBWipeResponse = void;

@ -1,122 +0,0 @@
// Query for items (POST).
import { Album, Id, Artist, Tag, Track, Name, StoreLinks, TagParentId, AlbumRefs, TrackDetails, ArtistDetails } 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 type QueryResponseTrackDetails = (Track & Name & StoreLinks & TrackDetails & Id);
export type QueryResponseArtistDetails = (Artist & Name & StoreLinks & Id);
export type QueryResponseTagDetails = (Tag & Name & TagParentId & Id);
export type QueryResponseAlbumDetails = (Album & Name & StoreLinks & Id);
export interface QueryResponse {
tracks: QueryResponseTrackDetails[] | number[] | number, // Details | IDs | count, depending on QueryResponseType
artists: QueryResponseArtistDetails[] | number[] | number,
tags: QueryResponseTagDetails[] | number[] | number,
albums: QueryResponseAlbumDetails[] | 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);
}

@ -1,184 +0,0 @@
import {
Album,
Artist,
IntegrationData,
IntegrationDataWithId,
IntegrationDataWithSecret,
isIntegrationData,
isPartialIntegrationData,
PartialIntegrationData,
Tag,
Track,
TrackRefs,
ArtistRefs,
AlbumRefs,
TagParentId,
isTrackRefs,
isAlbumRefs,
isArtistRefs,
isTagParentId,
isName,
Name,
isTrack,
isArtist,
isAlbum,
isTag,
} 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 = Track;
// 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 = (Track & TrackRefs & Name);
export interface PostTrackResponse { id: number };
export const checkPostTrackRequest: (v: any) => boolean = (v: any) => isTrackRefs(v) && isName(v);
// Post new artist (POST).
export const PostArtistEndpoint = "/artist";
export type PostArtistRequest = (Artist & ArtistRefs & Name);
export interface PostArtistResponse { id: number };
export const checkPostArtistRequest: (v: any) => boolean = (v: any) => isArtistRefs(v) && isName(v);
// Post new album (POST).
export const PostAlbumEndpoint = "/album";
export type PostAlbumRequest = (Album & AlbumRefs & Name);
export interface PostAlbumResponse { id: number };
export const checkPostAlbumRequest: (v: any) => boolean = (v: any) => isAlbumRefs(v) && isName(v);
// Post new tag (POST).
export const PostTagEndpoint = "/tag";
export type PostTagRequest = (Tag & TagParentId & Name);
export interface PostTagResponse { id: number };
export const checkPostTagRequest: (v: any) => boolean = (v: any) => isTagParentId(v) && isName(v);
// 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 = (Track & TrackRefs & Name);
export type PutTrackResponse = void;
export const checkPutTrackRequest: (v: any) => boolean = (v: any) => isTrackRefs(v) && isName(v);
// Replace artist (PUT).
export const PutArtistEndpoint = "/artist/:id";
export type PutArtistRequest = (Artist & ArtistRefs);
export type PutArtistResponse = void;
export const checkPutArtistRequest: (v: any) => boolean = (v: any) => isArtistRefs(v) && isName(v);;
// Replace album (PUT).
export const PutAlbumEndpoint = "/album/:id";
export type PutAlbumRequest = (Album & AlbumRefs);
export type PutAlbumResponse = void;
export const checkPutAlbumRequest: (v: any) => boolean = (v: any) => isAlbumRefs(v) && isName(v);;
// Replace tag (PUT).
export const PutTagEndpoint = "/tag/:id";
export type PutTagRequest = (Tag & TagParentId);
export type PutTagResponse = void;
export const checkPutTagRequest: (v: any) => boolean = (v: any) => isTagParentId(v) && isName(v);;
// 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 = Track;
export type PatchTrackResponse = void;
export const checkPatchTrackRequest: (v: any) => boolean = isTrack;
// Modify artist (PATCH).
export const PatchArtistEndpoint = "/artist/:id";
export type PatchArtistRequest = Artist;
export type PatchArtistResponse = void;
export const checkPatchArtistRequest: (v: any) => boolean = isArtist;
// Modify album (PATCH).
export const PatchAlbumEndpoint = "/album/:id";
export type PatchAlbumRequest = Album;
export type PatchAlbumResponse = void;
export const checkPatchAlbumRequest: (v: any) => boolean = isAlbum;
// Modify tag (PATCH).
export const PatchTagEndpoint = "/tag/:id";
export type PatchTagRequest = Tag;
export type PatchTagResponse = void;
export const checkPatchTagRequest: (v: any) => boolean = isTag;
// 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;

@ -1,262 +0,0 @@
// Enumerates the different kinds of resources dealt with by the API.
export enum ResourceType {
Track = "track",
Artist = "artist",
Album = "album",
Tag = "tag"
}
export interface Id {
id: number,
}
export function isId(q : any) : q is Id {
return 'id' in q;
}
export interface Name {
name: string,
}
export function isName(q : any) : q is Name {
return 'name' in q;
}
export interface StoreLinks {
storeLinks: string[],
}
export interface TrackRefs {
albumId: number | null,
artistIds: number[],
tagIds: number[],
}
export interface TrackDetails {
artists: (Artist & Id)[],
album: (Album & Id) | null,
tags: (Tag & Id)[],
}
export interface Track {
mbApi_typename: "track",
name?: string,
id?: number,
storeLinks?: string[],
albumId?: number | null,
artistIds?: number[],
tagIds?: number[],
artists?: (Artist & Id & Name)[],
album?: (Album & Id & Name) | null,
tags?: (Tag & Id & Name)[],
}
export function isTrack(q: any): q is Track {
return q.mbApi_typename && q.mbApi_typename === "track";
}
export function isTrackRefs(q: any): q is TrackRefs {
return isTag(q) && 'albumId' in q && 'artistIds' in q && 'tagIds' in q;
}
export function isTrackDetails(q: any): q is TrackDetails {
return isTag(q) && 'album' in q && 'artists' in q && 'tags' in q;
}
export interface ArtistRefs {
albumIds: number[],
tagIds: number[],
trackIds: number[],
}
export interface ArtistDetails {
albums?: (Album & Id)[],
tags?: (Tag & Id)[],
tracks?: (Track & Id)[],
}
export interface Artist {
mbApi_typename: "artist",
name?: string,
id?: number,
storeLinks?: string[],
albumIds?: number[],
tagIds?: number[],
trackIds?: number[],
albums?: (Album & Id)[],
tags?: (Tag & Id)[],
tracks?: (Track & Id)[],
}
export function isArtist(q: any): q is Artist {
return q.mbApi_typename && q.mbApi_typename === "artist";
}
export function isArtistRefs(q: any): q is ArtistRefs {
return isTag(q) && 'albumIds' in q && 'trackIds' in q && 'tagIds' in q;
}
export function isArtistDetails(q: any): q is ArtistDetails {
return isTag(q) && 'albums' in q && 'tracks' in q && 'tags' in q;
}
export interface Album {
mbApi_typename: "album",
name?: string,
id?: number,
storeLinks?: string[],
artistIds?: number[],
trackIds?: number[],
tagIds?: number[],
artists?: (Artist & Id)[],
tracks?: (Track & Id)[],
tags?: (Tag & Id)[],
}
export interface AlbumRefs {
artistIds: number[],
trackIds: number[],
tagIds: number[],
}
export interface AlbumDetails {
artists: (Artist & Id)[],
tracks: (Track & Id)[],
tags: (Tag & Id)[],
}
export function isAlbum(q: any): q is Album {
return q.mbApi_typename && q.mbApi_typename === "album";
}
export function isAlbumRefs(q: any): q is AlbumRefs {
return isTag(q) && 'artistIds' in q && 'trackIds' in q && 'tagIds' in q;
}
export function isAlbumDetails(q: any): q is AlbumDetails {
return isTag(q) && 'artists' in q && 'tracks' in q && 'tags' in q;
}
export interface Tag {
mbApi_typename: "tag",
name?: string,
id?: number,
parentId?: number | null,
parent?: (Tag & Id) | null,
childIds?: number[],
}
export interface TagParentId {
parentId: number | null,
}
export interface TagChildIds {
childIds: number[],
}
export interface TagDetails {
parent: (Tag & Id) | null,
}
export function isTag(q: any): q is Tag {
return q.mbApi_typename && q.mbApi_typename === "tag";
}
export function isTagParentId(q: any): q is TagParentId {
return isTag(q) && 'parentId' in q;
}
export function isTagDetails(q: any): q is TagDetails {
return isTag(q) && 'parent' 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,
}

@ -1 +0,0 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2931 2931" width="2931" height="2931"><style>.st0{fill:#2ebd59}</style><path class="st0" d="M1465.5 0C656.1 0 0 656.1 0 1465.5S656.1 2931 1465.5 2931 2931 2274.9 2931 1465.5C2931 656.2 2274.9.1 1465.5 0zm672.1 2113.6c-26.3 43.2-82.6 56.7-125.6 30.4-344.1-210.3-777.3-257.8-1287.4-141.3-49.2 11.3-98.2-19.5-109.4-68.7-11.3-49.2 19.4-98.2 68.7-109.4C1242.1 1697.1 1721 1752 2107.3 1988c43 26.5 56.7 82.6 30.3 125.6zm179.3-398.9c-33.1 53.8-103.5 70.6-157.2 37.6-393.8-242.1-994.4-312.2-1460.3-170.8-60.4 18.3-124.2-15.8-142.6-76.1-18.2-60.4 15.9-124.1 76.2-142.5 532.2-161.5 1193.9-83.3 1646.2 194.7 53.8 33.1 70.8 103.4 37.7 157.1zm15.4-415.6c-472.4-280.5-1251.6-306.3-1702.6-169.5-72.4 22-149-18.9-170.9-91.3-21.9-72.4 18.9-149 91.4-171 517.7-157.1 1378.2-126.8 1922 196 65.1 38.7 86.5 122.8 47.9 187.8-38.5 65.2-122.8 86.7-187.8 48z"/></svg>

Before

Width:  |  Height:  |  Size: 907 B

@ -1,16 +0,0 @@
<svg version="1.1" id="Layer_1"
xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 176 176"
enable-background="new 0 0 176 176">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="176" width="176" x="8" y="-184"></sliceSourceBounds>
</sfw>
</metadata>
<g id="XMLID_167_">
<circle id="XMLID_791_" fill="#FF0000" cx="88" cy="88" r="88"/>
<path id="XMLID_42_" fill="#FFFFFF" d="M88,46c23.1,0,42,18.8,42,42s-18.8,42-42,42s-42-18.8-42-42S64.9,46,88,46 M88,42
c-25.4,0-46,20.6-46,46s20.6,46,46,46s46-20.6,46-46S113.4,42,88,42L88,42z"/>
<polygon id="XMLID_274_" fill="#FFFFFF" points="72,111 111,87 72,65 "/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 696 B

@ -1,23 +1,15 @@
import React, { useEffect } from 'react';
import React, { useReducer, Reducer } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors';
import AppBar, { AppBarTab } from './appbar/AppBar';
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/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';
import { useAuth, ProvideAuth } from '../lib/useAuth';
import RegisterWindow from './windows/register/RegisterWindow';
import SettingsWindow from './windows/settings/SettingsWindow';
import { ErrorBoundary } from 'react-error-boundary';
import { ProvideIntegrations } from '../lib/integration/useIntegrations';
import ManageLinksWindow from './windows/manage_links/ManageLinksWindow';
import ManageWindow, { ManageWhat } from './windows/manage/ManageWindow';
import TrackWindow from './windows/track/TrackWindow';
import AppBar from './appbar/AppBar';
import QueryWindow from './windows/QueryWindow';
import { NewTabProps } from './appbar/AddTabMenu';
import { newWindowState, newWindowReducer, WindowType } from './windows/Windows';
import ArtistWindow from './windows/ArtistWindow';
import AlbumWindow from './windows/AlbumWindow';
import TagWindow from './windows/TagWindow';
import SongWindow from './windows/SongWindow';
var _ = require('lodash');
const darkTheme = createMuiTheme({
palette: {
@ -28,85 +20,141 @@ const darkTheme = createMuiTheme({
},
});
function PrivateRoute(props: any) {
const { children, ...rest } = props;
let auth = useAuth();
return <Route {...rest}
render={({ location }) =>
auth.user ? (
children
) : (
<Redirect
to={{
pathname: "/login",
state: { from: location }
}}
/>
)
export interface MainWindowState {
tabStates: any[],
tabReducers: Reducer<any, any>[],
tabTypes: WindowType[],
activeTab: number,
}
export enum MainWindowStateActions {
SetActiveTab = "setActiveTab",
DispatchToTab = "dispatchToTab",
CloseTab = "closeTab",
AddTab = "addTab",
}
export function MainWindowReducer(state: MainWindowState, action: any) {
switch (action.type) {
case MainWindowStateActions.SetActiveTab:
return { ...state, activeTab: action.value }
case MainWindowStateActions.CloseTab:
const newSize = state.tabStates.length - 1;
return {
...state,
tabStates: state.tabStates.filter((i: any, idx: number) => idx != action.idx),
tabReducers: state.tabReducers.filter((i: any, idx: number) => idx != action.idx),
tabTypes: state.tabTypes.filter((i: any, idx: number) => idx != action.idx),
activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab,
}
case MainWindowStateActions.AddTab:
return {
...state,
tabStates: [...state.tabStates, action.tabState],
tabReducers: [...state.tabReducers, action.tabReducer],
tabTypes: [...state.tabTypes, action.tabType],
}
case MainWindowStateActions.DispatchToTab:
return {
...state,
tabStates: state.tabStates.map((item: any, i: number) => {
return i === action.idx ?
state.tabReducers[i](item, action.tabAction) :
item;
})
}
default:
throw new Error("Unimplemented MainWindow state update.")
}
/>
}
export default function MainWindow(props: any) {
const [state, dispatch] = useReducer(MainWindowReducer, {
tabStates: [
newWindowState[WindowType.Query](),
newWindowState[WindowType.Song](),
newWindowState[WindowType.Album](),
newWindowState[WindowType.Artist](),
newWindowState[WindowType.Tag](),
],
tabReducers: [
newWindowReducer[WindowType.Query],
newWindowReducer[WindowType.Song],
newWindowReducer[WindowType.Album],
newWindowReducer[WindowType.Artist],
newWindowReducer[WindowType.Tag],
],
tabTypes: [
WindowType.Query,
WindowType.Song,
WindowType.Album,
WindowType.Artist,
WindowType.Tag,
],
activeTab: 0
})
const windows = state.tabStates.map((tabState: any, i: number) => {
const tabDispatch = (action: any) => {
dispatch({
type: MainWindowStateActions.DispatchToTab,
tabAction: action,
idx: i
});
}
switch (state.tabTypes[i]) {
case WindowType.Query:
return <QueryWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Artist:
return <ArtistWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Album:
return <AlbumWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Tag:
return <TagWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Song:
return <SongWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
default:
throw new Error("Unimplemented window type");
}
});
return <ThemeProvider theme={darkTheme}>
<CssBaseline />
<ProvideAuth>
<ProvideIntegrations>
<BrowserRouter>
<Switch>
<Route exact path="/">
<Redirect to={"/query"} />
</Route>
<Route path="/login">
<AppBar selectedTab={null} />
<LoginWindow />
</Route>
<Route path="/register">
<AppBar selectedTab={null} />
<RegisterWindow />
</Route>
<PrivateRoute path="/settings">
<AppBar selectedTab={null} />
<SettingsWindow />
</PrivateRoute>
<PrivateRoute path="/query">
<AppBar selectedTab={AppBarTab.Query} />
<QueryWindow />
</PrivateRoute>
<PrivateRoute path="/artist/:id">
<AppBar selectedTab={AppBarTab.Browse} />
<ArtistWindow />
</PrivateRoute>
<PrivateRoute path="/tag/:id">
<AppBar selectedTab={AppBarTab.Browse} />
<TagWindow />
</PrivateRoute>
<PrivateRoute path="/album/:id">
<AppBar selectedTab={AppBarTab.Browse} />
<AlbumWindow />
</PrivateRoute>
<PrivateRoute path="/track/:id">
<AppBar selectedTab={AppBarTab.Browse} />
<TrackWindow />
</PrivateRoute>
<PrivateRoute path="/manage/tags">
<AppBar selectedTab={AppBarTab.Manage} />
<ManageWindow selectedWindow={ManageWhat.Tags} />
</PrivateRoute>
<PrivateRoute path="/manage/links">
<AppBar selectedTab={AppBarTab.Manage} />
<ManageWindow selectedWindow={ManageWhat.Links} />
</PrivateRoute>
<PrivateRoute path="/manage/Data">
<AppBar selectedTab={AppBarTab.Manage} />
<ManageWindow selectedWindow={ManageWhat.Data} />
</PrivateRoute>
<PrivateRoute exact path="/manage">
<Redirect to={"/manage/tags"} />
</PrivateRoute>
</Switch>
</BrowserRouter>
</ProvideIntegrations>
</ProvideAuth>
<AppBar
tabLabels={state.tabStates.map((s: any) => s.tabLabel)}
selectedTab={state.activeTab}
setSelectedTab={(t: number) => dispatch({ type: MainWindowStateActions.SetActiveTab, value: t })}
onCloseTab={(t: number) => dispatch({ type: MainWindowStateActions.CloseTab, idx: t })}
onAddTab={(w: NewTabProps) => {
dispatch({
type: MainWindowStateActions.AddTab,
tabState: newWindowState[w.windowType](),
tabReducer: newWindowReducer[w.windowType],
tabType: w.windowType,
})
}}
/>
{windows[state.activeTab]}
</ThemeProvider>
}

@ -0,0 +1,32 @@
import React from 'react';
import { WindowType } from '../windows/Windows';
import { Menu, MenuItem } from '@material-ui/core';
export interface NewTabProps {
windowType: WindowType,
}
export interface IProps {
anchorEl: null | HTMLElement,
onClose: () => void,
onCreateTab: (q: NewTabProps) => void,
}
export default function AddTabMenu(props: IProps) {
return <Menu
anchorEl={props.anchorEl}
keepMounted
open={Boolean(props.anchorEl)}
onClose={props.onClose}
>
<MenuItem disabled={true}>New Tab</MenuItem>
<MenuItem
onClick={() => {
props.onClose();
props.onCreateTab({
windowType: WindowType.Query,
})
}}
>{WindowType.Query}</MenuItem>
</Menu>
}

@ -1,122 +1,85 @@
import React from 'react';
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton, Typography, Menu, MenuItem } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import InfoIcon from '@material-ui/icons/Info';
import BuildIcon from '@material-ui/icons/Build';
import { Link, useHistory } from 'react-router-dom';
import { useAuth } from '../../lib/useAuth';
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import AddIcon from '@material-ui/icons/Add';
import AddTabMenu, { NewTabProps } from './AddTabMenu';
export enum AppBarTab {
Browse = 0,
Query,
Manage,
export interface IProps {
tabLabels: string[],
selectedTab: number,
setSelectedTab: (n: number) => void,
onCloseTab: (idx: number) => void,
onAddTab: (w: NewTabProps) => void,
}
export const appBarTabProps: Record<any, any> = {
[AppBarTab.Query]: {
label: <Box display="flex"><SearchIcon /><Box ml={.5}/><Typography variant="button">Query</Typography></Box>,
path: "/query",
},
[AppBarTab.Manage]: {
label: <Box display="flex"><BuildIcon /><Box ml={.5}/><Typography variant="button">Manage</Typography></Box>,
path: "/manage",
},
[AppBarTab.Browse]: {
label: <Box display="flex"><InfoIcon /><Box ml={.5}/><Typography variant="button">Browse</Typography></Box>,
path: undefined,
},
}
export function UserMenu(props: {
position: null | number[],
open: boolean,
onLogout: () => void,
export interface TabProps {
onClose: () => void,
}) {
let auth = useAuth();
let history = useHistory();
}
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
export function Tab(props: any) {
const { onClose, label, ...restProps } = props;
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
const labelElem = <Box
display="flex"
alignItems="center"
justifyContent="center"
>
{label}
<Box ml={1}>
<IconButton
size="small"
color="inherit"
onClick={onClose}
>
<Box p={2}>
{auth.user?.email || "Unknown user"}
<MenuItem
onClick={() => {
props.onClose();
history.replace('/settings')
}}
>User Settings</MenuItem>
<MenuItem
onClick={() => {
props.onClose();
props.onLogout();
}}
>Sign out</MenuItem>
<CloseIcon />
</IconButton>
</Box>
</Menu>
</Box>;
return <MuiTab
label={labelElem}
{...restProps}
/>
}
export default function AppBar(props: {
selectedTab: AppBarTab | null
}) {
let history = useHistory();
let auth = useAuth();
export default function AppBar(props: IProps) {
const [addMenuAnchorEl, setAddMenuAnchorEl] = React.useState<null | HTMLElement>(null);
const [userMenuPos, setUserMenuPos] = React.useState<null | number[]>(null);
const onOpenUserMenu = (e: any) => {
setUserMenuPos([e.clientX, e.clientY])
const onOpenAddMenu = (event: any) => {
setAddMenuAnchorEl(event.currentTarget);
};
const onCloseUserMenu = () => {
setUserMenuPos(null);
const onCloseAddMenu = () => {
setAddMenuAnchorEl(null);
};
const onAddTab = (w: NewTabProps) => {
props.onAddTab(w);
};
return <>
<MuiAppBar position="static" style={{ background: 'grey' }}>
<Box display="flex" alignItems="center">
<Link to="/">
<Box m={0.5} display="flex" alignItems="center">
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img>
</Box>
</Link>
<Box flexGrow={1}>
{auth.user && <Tabs
<Tabs
value={props.selectedTab}
onChange={(e: any, val: AppBarTab) => {
let path = appBarTabProps[val].path
path && history.push(appBarTabProps[val].path)
}}
onChange={(e: any, v: number) => props.setSelectedTab(v)}
variant="scrollable"
scrollButtons="auto"
>
{Object.keys(appBarTabProps).map((tab: any, idx: number) => <MuiTab
label={appBarTabProps[tab].label}
{props.tabLabels.map((l: string, idx: number) => <Tab
label={l}
value={idx}
disabled={!(appBarTabProps[tab].path) && idx !== props.selectedTab}
onClose={() => props.onCloseTab(idx)}
/>)}
</Tabs>}
</Box>
{auth.user && <IconButton
color="primary"
onClick={(e: any) => { onOpenUserMenu(e) }}
>{auth.user.icon}</IconButton>}
</Tabs>
<IconButton color="inherit" onClick={onOpenAddMenu}><AddIcon /></IconButton>
</Box>
</MuiAppBar>
<UserMenu
position={userMenuPos}
open={userMenuPos !== null}
onClose={onCloseUserMenu}
onLogout={auth.signout}
<AddTabMenu
anchorEl={addMenuAnchorEl}
onClose={onCloseAddMenu}
onCreateTab={onAddTab}
/>
</>
}

@ -1,13 +0,0 @@
import React from 'react';
import { Box, Button } from '@material-ui/core';
export default function DiscardChangesButton(props: any) {
return <Box>
<Button
{...props}
variant="contained" color="primary"
>
Discard Changes
</Button>
</Box>
}

@ -1,118 +0,0 @@
import React, { useState } from 'react';
import { Button, Dialog, DialogActions, Divider, Typography, Box, TextField, IconButton } from "@material-ui/core";
import { ExternalLinksEditor } from './ExternalLinksEditor';
import UndoIcon from '@material-ui/icons/Undo';
import { ResourceType } from '../../api/api';
let _ = require('lodash')
export enum EditablePropertyType {
Text = 0,
}
export interface EditableProperty {
metadataKey: string,
title: string,
type: EditablePropertyType
}
function EditTextProperty(props: {
title: string,
originalValue: string,
currentValue: string,
onChange: (v: string) => void
}) {
return <Box display="flex" alignItems="center" width="100%">
<TextField
// Here we "abuse" the label to show the original title.
// emptying the text box means going back to the original.
variant="outlined"
value={props.currentValue}
label={props.title}
helperText={(props.currentValue != props.originalValue) &&
"Current: " + props.originalValue || undefined}
error={(props.currentValue != props.originalValue)}
onChange={(e: any) => {
props.onChange((e.target.value == "") ?
props.originalValue : e.target.value)
}}
fullWidth={true}
/>
{props.currentValue != props.originalValue && <IconButton
onClick={() => {
props.onChange(props.originalValue)
}}
><UndoIcon /></IconButton>}
</Box>
}
function PropertyEditor(props: {
originalMetadata: any,
currentMetadata: any,
onChange: (metadata: any) => void,
editableProperties: EditableProperty[]
}) {
return <Box display="flex" width="100%">
{props.editableProperties.map(
(p: EditableProperty) => {
if (p.type == EditablePropertyType.Text) {
return <EditTextProperty
title={p.title}
originalValue={props.originalMetadata[p.metadataKey]}
currentValue={props.currentMetadata[p.metadataKey]}
onChange={(v: string) => props.onChange({ ...props.currentMetadata, [p.metadataKey]: v })}
/>
}
return undefined;
}
)}
</Box >
}
export default function EditItemDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: any) => void,
id: number,
metadata: any,
defaultExternalLinksQuery: string,
editableProperties: EditableProperty[],
resourceType: ResourceType,
editStoreLinks: boolean,
}) {
let [editingMetadata, setEditingMetadata] = useState<any>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<PropertyEditor
originalMetadata={props.metadata}
currentMetadata={editingMetadata}
onChange={setEditingMetadata}
editableProperties={props.editableProperties}
/>
{props.editStoreLinks && <><Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: any) => setEditingMetadata(v)}
defaultQuery={props.defaultExternalLinksQuery}
resourceType={props.resourceType}
/></>}
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Box, IconButton, TextField } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import UndoIcon from '@material-ui/icons/Undo';
import { useTheme } from '@material-ui/core/styles';
// This component is an editable text. It shows up as normal text,
// but will display an edit icon on hover. When clicked, this
// enables a text input to make a new suggestion.
// The text can show a striked-through version of the old text,
// with the new value next to it and an undo button.
export interface IProps {
defaultValue: string,
changedValue: string | null, // Null == not changed
editingValue: string | null, // Null == not editing
editingLabel: string,
onChangeEditingValue: (v: string | null) => void,
onChangeChangedValue: (v: string | null) => void,
}
export default function EditableText(props: IProps) {
let editingValue = props.editingValue;
let defaultValue = props.defaultValue;
let changedValue = props.changedValue;
let onChangeEditingValue = props.onChangeEditingValue;
let onChangeChangedValue = props.onChangeChangedValue;
let editing = editingValue !== null;
let editingLabel = props.editingLabel;
const theme = useTheme();
const [hovering, setHovering] = useState<Boolean>(false);
const editButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => onChangeEditingValue(changedValue || defaultValue)}
>
<EditIcon />
</IconButton>
</Box>
const discardChangesButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => {
onChangeChangedValue(null);
onChangeEditingValue(null);
}}
>
<UndoIcon />
</IconButton>
</Box>
if (editing) {
return <Box display="flex" alignItems="center">
<TextField
variant="outlined"
value={editingValue || ""}
label={editingLabel}
inputProps={{ style: { fontSize: '2rem' } }}
onChange={(e: any) => onChangeEditingValue(e.target.value)}
/>
<IconButton
onClick={() => {
onChangeChangedValue(editingValue === defaultValue ? null : editingValue);
onChangeEditingValue(null);
}}
><CheckIcon /></IconButton>
</Box>
} else if (changedValue) {
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>
<del style={{ color: theme.palette.text.secondary }}>{defaultValue}</del>
{changedValue}
{editButton}
{discardChangesButton}
</Box>
}
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>{defaultValue}{editButton}</Box>;
}

@ -1,222 +0,0 @@
import { IntegrationWith, Name, ResourceType, StoreLinks } from '../../api/api';
import { IntegrationState, useIntegrations } from '../../lib/integration/useIntegrations';
import StoreLinkIcon, { whichStore } from './StoreLinkIcon';
import { $enum } from "ts-enum-util";
import React, { useEffect, useState } from 'react';
import { IntegrationAlbum, IntegrationArtist, IntegrationFeature, IntegrationTrack } from '../../lib/integration/Integration';
import { Box, List, ListItem, ListItemIcon, ListItemText, IconButton, Typography, FormControl, FormControlLabel, MenuItem, Radio, RadioGroup, Select, TextField } from '@material-ui/core';
import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search';
import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete';
let _ = require('lodash')
export type ItemWithExternalLinksProperties = StoreLinks & Name;
export function ProvideLinksWidget(props: {
providers: IntegrationState[],
metadata: ItemWithExternalLinksProperties,
store: IntegrationWith,
onChange: (link: string | undefined) => void,
defaultQuery: string,
resourceType: ResourceType,
}) {
let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined
);
let [query, setQuery] = useState<string>(props.defaultQuery)
let [results, setResults] = useState<
IntegrationTrack[] | IntegrationAlbum[] | IntegrationArtist[] | undefined>(undefined);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined;
let currentLink = props.metadata.storeLinks ? props.metadata.storeLinks.find(
(l: string) => whichStore(l) === props.store
) : undefined;
// Ensure results are cleared when input state changes.
useEffect(() => {
setResults(undefined);
setQuery(props.defaultQuery);
}, [props.store, props.providers, props.metadata])
return <Box display="flex" flexDirection="column" alignItems="left">
<Box display="flex" alignItems="center">
<Typography>Search using:</Typography>
<Box ml={2} />
<Select
value={selectedProviderIdx}
onChange={(e: any) => setSelectedProviderIdx(e.target.value)}
>
{props.providers.map((p: IntegrationState, idx: number) => {
return <MenuItem value={idx}>{p.properties.name}</MenuItem>
})}
</Select>
</Box>
<TextField
value={query}
onChange={(e: any) => setQuery(e.target.value)}
label="Query"
fullWidth
/>
<IconButton
onClick={() => {
switch (props.resourceType) {
case ResourceType.Track:
selectedProvider?.integration.searchTrack(query, 10)
.then((tracks: IntegrationTrack[]) => setResults(tracks))
break;
case ResourceType.Album:
selectedProvider?.integration.searchAlbum(query, 10)
.then((albums: IntegrationAlbum[]) => setResults(albums))
break;
case ResourceType.Artist:
selectedProvider?.integration.searchArtist(query, 10)
.then((artists: IntegrationArtist[]) => setResults(artists))
break;
}
}}
><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results && (results as any).map((result: IntegrationTrack | IntegrationAlbum | IntegrationArtist, idx: number) => {
var pretty = "";
switch (props.resourceType) {
case ResourceType.Track:
let rt = result as IntegrationTrack;
pretty = `"${rt.title}"
${rt.artist && ` by ${rt.artist.name}`}
${rt.album && ` (${rt.album.name})`}`;
break;
case ResourceType.Album:
let ral = result as IntegrationAlbum;
pretty = `"${ral.name}"
${ral.artist && ` by ${ral.artist.name}`}`;
break;
case ResourceType.Artist:
let rar = result as IntegrationArtist;
pretty = rar.name || "(Unknown Artist)";
break;
}
return <FormControlLabel
value={result.url || idx}
control={<Radio checked={(result.url || idx) === currentLink} />}
label={<Box display="flex" alignItems="center">
{pretty}
<a href={result.url || ""} target="_blank">
<IconButton><OpenInNewIcon /></IconButton>
</a>
</Box>}
/>
})}
{results && results.length === 0 && <Typography>No results were found. Try adjusting the query manually.</Typography>}
</RadioGroup>
</FormControl>
</Box >
}
export function ExternalLinksEditor(props: {
metadata: ItemWithExternalLinksProperties,
original: ItemWithExternalLinksProperties,
onChange: (v: any) => void,
defaultQuery: string,
resourceType: ResourceType,
}) {
let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations();
let getLinksSet = (metadata: ItemWithExternalLinksProperties) => {
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) {
maybeLink = link;
}
})
return {
...prev,
[store]: maybeLink,
}
}, {});
}
let linksSet: Record<string, string | null> = getLinksSet(props.metadata);
let originalLinksSet: Record<string, string | null> = getLinksSet(props.original);
let store = $enum(IntegrationWith).getValues()[selectedIdx];
let providers: IntegrationState[] = Array.isArray(integrations.state) ?
integrations.state.filter(
(iState: IntegrationState) => (
iState.integration.getFeatures().includes(IntegrationFeature.SearchTrack) &&
iState.integration.providesStoreLink() === store
)
) : [];
return <Box display="flex" width="100%">
<Box width="30%">
<List>
{$enum(IntegrationWith).getValues().map((store: string, idx: number) => {
let maybeLink = linksSet[store];
let color: string | undefined =
(linksSet[store] && !originalLinksSet[store]) ? "lightgreen" :
(!linksSet[store] && originalLinksSet[store]) ? "red" :
(linksSet[store] && originalLinksSet[store] && linksSet[store] !== originalLinksSet[store]) ? "orange" :
undefined;
return <ListItem
selected={selectedIdx === idx}
onClick={(e: any) => setSelectedIdx(idx)}
button
>
<ListItemIcon>{linksSet[store] !== null ? <CheckIcon style={{ color: color }} /> : <CancelIcon style={{ color: color }} />}</ListItemIcon>
<ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon>
<ListItemText style={{ color: color }} primary={store} />
{maybeLink && <a href={maybeLink} target="_blank">
<ListItemIcon><IconButton><OpenInNewIcon style={{ color: color }} /></IconButton></ListItemIcon>
</a>}
{maybeLink && <ListItemIcon><IconButton
onClick={() => {
let newLinks = props.metadata.storeLinks?.filter(
(l: string) => whichStore(l) !== store
)
props.onChange({
...props.metadata,
storeLinks: newLinks,
});
}}
><DeleteIcon style={{ color: color }} />
</IconButton></ListItemIcon>}
</ListItem>
})}
</List>
</Box>
<Box ml={2} width="60%">
{providers.length === 0 ?
<Typography>None of your configured integrations provides URL links for {store}.</Typography> :
<ProvideLinksWidget
providers={providers}
metadata={props.metadata}
store={store}
onChange={(link: string | undefined) => {
let removed = props.metadata.storeLinks?.filter(
(link: string) => whichStore(link) !== store
) || [];
let newValue = link ? [...removed, link] : removed;
if (!_.isEqual(new Set(newValue), new Set(props.metadata.storeLinks || []))) {
props.onChange({
...props.metadata,
storeLinks: newValue,
})
}
}}
defaultQuery={props.defaultQuery}
resourceType={props.resourceType}
/>
}
</Box>
</Box >
}

@ -1,31 +0,0 @@
import { Button } from '@material-ui/core';
import React from 'react';
export default function FileUploadButton(props: any) {
const hiddenFileInput = React.useRef<null | any>(null);
const { onGetFile, ...restProps } = props;
const handleClick = (event: any) => {
if (hiddenFileInput) {
hiddenFileInput.current.click();
}
}
const handleChange = (event: any) => {
const fileUploaded = event.target.files[0];
onGetFile(fileUploaded);
};
return (
<>
<Button onClick={handleClick} {...restProps}>
{props.children}
</Button>
<input type="file"
ref={hiddenFileInput}
onChange={handleChange}
style={{ display: 'none' }}
/>
</>
);
};

@ -1,23 +0,0 @@
import React, { useState } from 'react';
import { TextField } from '@material-ui/core';
export default function MenuEditText(props: {
label: string,
onSubmit: (s: string) => void,
}) {
const [input, setInput] = useState("");
return <TextField
label={props.label}
variant="outlined"
value={input}
onChange={(e: any) => setInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
// User submitted free-form value.
props.onSubmit(input);
e.preventDefault();
}
}}
/>
}

@ -1,35 +1,27 @@
import React from 'react';
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 enum ExternalStore {
GooglePlayMusic = "GPM",
}
export interface IProps {
whichStore: IntegrationWith,
whichStore: ExternalStore,
}
export function whichStore(url: string) {
return Object.keys(IntegrationUrls).reduce((prev: string | undefined, cur: string) => {
if(url.includes(IntegrationUrls[cur as IntegrationWith])) {
return cur;
if(url.includes('play.google.com')) {
return ExternalStore.GooglePlayMusic;
}
return prev;
}, undefined);
return undefined;
}
export default function StoreLinkIcon(props: any) {
const { whichStore, style, ...restProps } = props;
let realStyle = (style === undefined) ?
{ height: '40px', width: '40px' } : style;
const { whichStore, ...restProps } = props;
switch(whichStore) {
case IntegrationWith.GooglePlayMusic:
return <GPMIcon {...restProps} style={realStyle} />;
case IntegrationWith.Spotify:
return <SpotifyIcon {...restProps} style={realStyle} />;
case IntegrationWith.YoutubeMusic:
return <YoutubeMusicIcon {...restProps} style={realStyle} />;
case ExternalStore.GooglePlayMusic:
return <GPMIcon {...restProps}/>;
default:
throw new Error("Unknown external store: " + whichStore)
}

@ -3,10 +3,7 @@ import { Menu, MenuItem } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item";
import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query';
import QBSelectWithRequest from './QBSelectWithRequest';
import { Requests, QueryBuilderTag } from './QueryBuilder';
import SpotifyClientCreds from '../../lib/integration/spotify/SpotifyClientCreds';
import { IntegrationUrls, IntegrationWith, QueryNodeOp } from '../../api/api';
import { $enum } from 'ts-enum-util';
import { Requests } from './QueryBuilder';
export interface MenuProps {
anchorEl: null | HTMLElement,
@ -15,19 +12,19 @@ export interface MenuProps {
requestFunctions: Requests,
}
export function createTagInfo(tag: QueryBuilderTag, allTags: QueryBuilderTag[]): TagQueryInfo {
const resolveName: (t: QueryBuilderTag) => string[] = (t: QueryBuilderTag) => {
export function createTagInfo(tag: any, allTags: any[]): TagQueryInfo {
const resolveName: (t: any) => string[] = (t: any) => {
if (t.parentId) {
const parent = allTags.filter((o: QueryBuilderTag) => o.id === t.parentId)[0];
return resolveName(parent).concat(t.name);
const parent = allTags.filter((o: any) => o.tagId === t.parentId)[0];
return [resolveName(parent), t.name];
}
return [t.name];
}
const resolveChildren: (t: QueryBuilderTag) => Set<number> = (t: QueryBuilderTag) => {
const resolveChildren: (t: any) => Set<number> = (t: any) => {
if (t.childIds.length > 0) {
const childSets: Set<number>[] = allTags.filter((o: QueryBuilderTag) => t.childIds.includes(o.id))
.map((child: QueryBuilderTag) => resolveChildren(child));
const childSets: Set<number>[] = allTags.filter((o: any) => t.childIds.includes(o.tagId))
.map((child: any) => resolveChildren(child));
var r = new Set<number>();
childSets.forEach((c: any) => {
@ -36,7 +33,7 @@ export function createTagInfo(tag: QueryBuilderTag, allTags: QueryBuilderTag[]):
return r;
}
return new Set([t.id]);
return new Set([t.tagId]);
}
return {
@ -50,14 +47,13 @@ export function QBAddElemMenu(props: MenuProps) {
let onClose = props.onClose;
interface TagItemProps {
tag: QueryBuilderTag,
allTags: QueryBuilderTag[],
tag: any,
allTags: any[],
}
const TagItem = (_props: TagItemProps) => {
if (_props.tag.childIds.length > 0) {
const children = _props.allTags.filter(
(tag: QueryBuilderTag) =>
_props.tag.childIds.includes(tag.id)
(tag: any) => _props.tag.childIds.includes(tag.tagId)
);
return <NestedMenuItem
@ -72,19 +68,12 @@ export function QBAddElemMenu(props: MenuProps) {
});
}}
>
{children.map((child: QueryBuilderTag) => <TagItem tag={child} allTags={_props.allTags} />)}
{children.map((child: any) => <TagItem tag={child} allTags={_props.allTags} />)}
</NestedMenuItem>
}
return <MenuItem
onClick={() => {
console.log("onCreateQuery: adding:", {
a: QueryLeafBy.TagInfo,
leafOp: QueryLeafOp.Equals,
b: createTagInfo(_props.tag, _props.allTags),
});
onClose();
props.onCreateQuery({
a: QueryLeafBy.TagInfo,
@ -98,7 +87,7 @@ export function QBAddElemMenu(props: MenuProps) {
}
const BaseTagsItem = (_props: any) => {
const [tags, setTags] = useState<QueryBuilderTag[] | null>(null);
const [tags, setTags] = useState<any[] | null>(null);
useEffect(() => {
(async () => {
@ -108,53 +97,13 @@ export function QBAddElemMenu(props: MenuProps) {
return tags ?
<>
{tags.filter((tag: QueryBuilderTag) => !tag.parentId).map((tag: QueryBuilderTag) => {
{tags.filter((tag: any) => !tag.parentId).map((tag: any) => {
return <TagItem tag={tag} allTags={tags} />
})}
</>
: <>...</>
}
const LinksItem = (_props: any) => {
let createLinksQuery = (store: IntegrationWith, isLinked: boolean) => {
let isLinkedQuery : QueryElem = {
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: '%' + IntegrationUrls[store] + '%'
};
if (isLinked) {
return isLinkedQuery;
}
return {
operands: [isLinkedQuery],
nodeOp: QueryNodeOp.Not,
};
};
return <>
{$enum(IntegrationWith).getValues().map((store: IntegrationWith) => {
return <NestedMenuItem
label={store}
parentMenuOpen={Boolean(anchorEl)}
>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, true));
}}
>Linked</MenuItem>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, false));
}}
>Not Linked</MenuItem>
</NestedMenuItem>
})}
</>
}
return <Menu
anchorEl={anchorEl}
keepMounted
@ -163,16 +112,16 @@ export function QBAddElemMenu(props: MenuProps) {
>
<MenuItem disabled={true}>New query element</MenuItem>
<NestedMenuItem
label="Track"
label="Song"
parentMenuOpen={Boolean(anchorEl)}
>
<QBSelectWithRequest
label="Title"
getNewOptions={props.requestFunctions.getTrackNames}
getNewOptions={props.requestFunctions.getSongTitles}
onSubmit={(s: string, exact: boolean) => {
onClose();
props.onCreateQuery({
a: QueryLeafBy.TrackName,
a: QueryLeafBy.SongTitle,
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s
});
@ -222,17 +171,5 @@ export function QBAddElemMenu(props: MenuProps) {
>
<BaseTagsItem />
</NestedMenuItem>
<NestedMenuItem
label="Metadata"
parentMenuOpen={Boolean(anchorEl)}
>
{/*TODO: generalize for other types of metadata in a scalable way*/}
<NestedMenuItem
label="Links"
parentMenuOpen={Boolean(anchorEl)}
>
<LinksItem />
</NestedMenuItem>
</NestedMenuItem>
</Menu >
}

@ -1,12 +1,10 @@
import React from 'react';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo, isLeafElem } from '../../lib/query/Query';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo } from '../../lib/query/Query';
import { Chip, Typography, IconButton, Box } from '@material-ui/core';
import { QBPlaceholder } from './QBPlaceholder';
import DeleteIcon from '@material-ui/icons/Delete';
import { Requests } from './QueryBuilder';
import stringifyList from '../../lib/stringifyList';
import { IntegrationUrls, IntegrationWith } from '../../api/api';
import { $enum } from 'ts-enum-util';
export interface ElemChipProps {
label: any,
@ -23,9 +21,8 @@ export function LabeledElemChip(props: ElemChipProps) {
export interface LeafProps {
elem: QueryLeafElem,
onReplace: (q: QueryElem | null) => void,
onReplace: (q: QueryElem) => void,
extraElements?: any,
modifier?: Modifier,
}
export function QBQueryElemArtistEquals(props: LeafProps) {
@ -81,47 +78,6 @@ export function QBQueryElemTagEquals(props: LeafProps) {
/>
}
export function QBQueryElemStoreLinked(props: LeafProps) {
// The store match string should be "%STORE%"
let storeUrl: string = (props.elem.b as string).replace(/%/g, '');
let store: string = '';
for (const [key, value] of Object.entries(IntegrationUrls)) {
if (value == storeUrl) {
store = key;
}
}
if (store == '') {
throw "Could not find store name for 'Store Linked' element";
}
if (props.modifier && props.modifier == Modifier.Not) {
return <LabeledElemChip
label={"No link to " + store}
extraElements={props.extraElements}
/>
}
return <LabeledElemChip
label={"Has link to " + store}
extraElements={props.extraElements}
/>
}
export function isStoreLinkedLeafElem(e: QueryElem): boolean {
if (isLeafElem(e) &&
e.leafOp === QueryLeafOp.Like &&
e.a === QueryLeafBy.StoreLinks) {
// There are multiple kinds of ops done on
// on storelinks. We need to examine the match
// string.
let isLinked_matchstrings: string[] =
$enum(IntegrationWith).getValues().map(
(store: IntegrationWith) => '%' + IntegrationUrls[store] + '%');
if (isLinked_matchstrings.includes(e.b as string)) {
return true;
}
}
return false;
}
export interface DeleteButtonProps {
onClick?: (e: any) => void,
}
@ -136,17 +92,8 @@ export function QBQueryElemDeleteButton(props: DeleteButtonProps) {
</IconButton>
}
// Modifiers are used to encode a node op's meaning
// into a leaf op element for visual representation.
// E.g. a NOT modifier can be added to show a "artist"
// leaf as "not by artist".
export enum Modifier {
Not = "NOT",
}
export interface IProps {
elem: QueryLeafElem,
modifier?: Modifier,
onReplace: (q: QueryElem | null) => void,
editingQuery: boolean,
requestFunctions: Requests,
@ -163,67 +110,61 @@ export function QBLeafElem(props: IProps) {
</Box>
: undefined;
if (e.a === QueryLeafBy.ArtistName &&
e.leafOp === QueryLeafOp.Equals &&
if (e.a == QueryLeafBy.ArtistName &&
e.leafOp == QueryLeafOp.Equals &&
typeof e.b == "string") {
return <QBQueryElemArtistEquals
{...props}
extraElements={extraElements}
/>
} else if (e.a === QueryLeafBy.ArtistName &&
e.leafOp === QueryLeafOp.Like &&
} else if (e.a == QueryLeafBy.ArtistName &&
e.leafOp == QueryLeafOp.Like &&
typeof e.b == "string") {
return <QBQueryElemArtistLike
{...props}
extraElements={extraElements}
/>
} else if (e.a === QueryLeafBy.AlbumName &&
e.leafOp === QueryLeafOp.Equals &&
} else if (e.a == QueryLeafBy.AlbumName &&
e.leafOp == QueryLeafOp.Equals &&
typeof e.b == "string") {
return <QBQueryElemAlbumEquals
{...props}
extraElements={extraElements}
/>
} else if (e.a === QueryLeafBy.AlbumName &&
e.leafOp === QueryLeafOp.Like &&
} else if (e.a == QueryLeafBy.AlbumName &&
e.leafOp == QueryLeafOp.Like &&
typeof e.b == "string") {
return <QBQueryElemAlbumLike
{...props}
extraElements={extraElements}
/>
} if (e.a === QueryLeafBy.TrackName &&
e.leafOp === QueryLeafOp.Equals &&
} if (e.a == QueryLeafBy.SongTitle &&
e.leafOp == QueryLeafOp.Equals &&
typeof e.b == "string") {
return <QBQueryElemTitleEquals
{...props}
extraElements={extraElements}
/>
} else if (e.a === QueryLeafBy.TrackName &&
e.leafOp === QueryLeafOp.Like &&
} else if (e.a == QueryLeafBy.SongTitle &&
e.leafOp == QueryLeafOp.Like &&
typeof e.b == "string") {
return <QBQueryElemTitleLike
{...props}
extraElements={extraElements}
/>
} else if (e.a === QueryLeafBy.TagInfo &&
e.leafOp === QueryLeafOp.Equals &&
} else if (e.a == QueryLeafBy.TagInfo &&
e.leafOp == QueryLeafOp.Equals &&
isTagQueryInfo(e.b)) {
return <QBQueryElemTagEquals
{...props}
extraElements={extraElements}
/>
} else if (e.leafOp === QueryLeafOp.Placeholder) {
}else if (e.leafOp == QueryLeafOp.Placeholder) {
return <QBPlaceholder
onReplace={props.onReplace}
requestFunctions={props.requestFunctions}
/>
} else if (isStoreLinkedLeafElem(e)) {
return <QBQueryElemStoreLinked
{...props}
extraElements={extraElements}
/>;
}
console.log("Unsupported leaf element:", e);
throw new Error("Unsupported leaf element");
}

@ -1,10 +1,11 @@
import React from 'react';
import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock';
import { QueryNodeElem, QueryNodeOp, QueryElem, simplify, QueryLeafElem, isLeafElem } from '../../lib/query/Query';
import { QueryNodeElem, QueryNodeOp, QueryElem, isNodeElem, simplify } from '../../lib/query/Query';
import { QBLeafElem } from './QBLeafElem';
import { QBQueryElem } from './QBQueryElem';
import { O_APPEND } from 'constants';
import { Requests } from './QueryBuilder';
import { Modifier, QBLeafElem } from './QBLeafElem';
export interface NodeProps {
elem: QueryNodeElem,
@ -23,8 +24,7 @@ export function QBNodeElem(props: NodeProps) {
} else {
ops.splice(idx, 1);
}
let newq = { operands: ops, nodeOp: e.nodeOp };
let newNode = simplify(newq, null);
let newNode = simplify({ operands: ops, nodeOp: e.nodeOp });
props.onReplace(newNode);
}
@ -37,21 +37,11 @@ export function QBNodeElem(props: NodeProps) {
/>
});
if (e.nodeOp === QueryNodeOp.And) {
if (e.nodeOp == QueryNodeOp.And) {
return <QBAndBlock>{children}</QBAndBlock>
} else if (e.nodeOp === QueryNodeOp.Or) {
} else if (e.nodeOp == QueryNodeOp.Or) {
return <QBOrBlock>{children}</QBOrBlock>
} else if (e.nodeOp === QueryNodeOp.Not &&
isLeafElem(e.operands[0])) {
return <QBLeafElem
elem={e.operands[0] as QueryLeafElem}
onReplace={props.onReplace}
editingQuery={props.editingQuery}
requestFunctions={props.requestFunctions}
modifier={Modifier.Not}
/>
}
console.log("Unsupported node element:", e);
throw new Error("Unsupported node element");
throw "Unsupported node element";
}

@ -19,7 +19,6 @@ export function QBPlaceholder(props: IProps & any) {
setAnchorEl(null);
};
const onCreate = (q: QueryElem) => {
console.log("Replacing placeholder by:", q);
props.onReplace(q);
};

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import CircularProgress from '@material-ui/core/CircularProgress';
@ -26,26 +26,38 @@ export default function QBSelectWithRequest(props: IProps & any) {
const loading: boolean = !options || options.forInput !== input;
const updateOptions = useCallback((forInput: string, options: any[]) => {
const updateOptions = (forInput: string, options: any[]) => {
if (forInput === input) {
setOptions({
forInput: forInput,
options: options,
});
}
}, [setOptions, input]);
}
const startRequest = useCallback((_input: string) => {
const startRequest = (_input: string) => {
setInput(_input);
(async () => {
const newOptions = await getNewOptions(_input);
updateOptions(_input, newOptions);
})();
}, [setInput, getNewOptions, updateOptions]);
};
// // Ensure a new request is made whenever the loading option is enabled.
// useEffect(() => {
// startRequest(input);
// }, []);
// Ensure options are cleared whenever the element is closed.
// useEffect(() => {
// if (!open) {
// setOptions(null);
// }
// }, [open]);
useEffect(() => {
startRequest(input);
}, [input, startRequest]);
}, [input]);
const onInputChange = (e: any, val: any, reason: any) => {
if (reason === 'reset') {

@ -3,15 +3,19 @@ import { Box } from '@material-ui/core';
import QBQueryButton from './QBEditButton';
import { QBQueryElem } from './QBQueryElem';
import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/query/Query';
import { Tag, TagChildIds, TagParentId, Name, Id } from '../../api/api';
export type QueryBuilderTag = (Tag & TagChildIds & TagParentId & Name & Id);
export interface TagItem {
name: string,
id: number,
childIds: number[],
parentId?: number,
}
export interface Requests {
getArtists: (filter: string) => Promise<string[]>,
getAlbums: (filter: string) => Promise<string[]>,
getTrackNames: (filter: string) => Promise<string[]>,
getTags: () => Promise<QueryBuilderTag[]>,
getSongTitles: (filter: string) => Promise<string[]>,
getTags: () => Promise<TagItem[]>,
}
export interface IProps {
@ -23,13 +27,12 @@ export interface IProps {
}
export default function QueryBuilder(props: IProps) {
const simpleQuery = simplify(props.query, null);
const simpleQuery = simplify(props.query);
const showQuery = props.editing ?
addPlaceholders(simpleQuery, null) : simpleQuery;
const onReplace = (q: any) => {
const newQ = removePlaceholders(q);
console.log("Removed placeholders:", q, newQ)
props.onChangeEditing(false);
props.onChangeQuery(newQ);
}

@ -1,71 +1,32 @@
import React from 'react';
import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody, Chip, Box, Button } from '@material-ui/core';
import stringifyList from '../../lib/stringifyList';
import { useHistory } from 'react-router';
import { Artist, QueryResponseTrackDetails, Tag, Name, Id, TagDetails, QueryResponseArtistDetails, QueryResponseAlbumDetails } from '../../api/api';
import { isTemplateHead } from 'typescript';
function getFullTagNames(item: any,
getTagName: (tag: any) => string,
getTagParent: (tag: any) => any,
getItemTags: (item: any) => any[]): string[][] {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [getTagName(tag)];
const parent = getTagParent(tag);
if (parent) { r = resolveTag(parent).concat(r); }
return r;
}
return (getItemTags(item) || []).map((tag: Tag) => resolveTag(tag));
}
function getFullTagIds(item: any,
getTagId: (tag: any) => number,
getTagParent: (tag: any) => any,
getItemTags: (item: any) => any[]): number[][] {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [getTagId(tag)];
const parent = getTagParent(tag);
if (parent) { r = resolveTag(parent).concat(r); }
return r;
}
return (getItemTags(item) || []).map((tag: Tag) => resolveTag(tag));
}
export enum ColumnType {
Text = 0,
Tags,
}
export interface TextColumnData {
}
export interface TagsColumnData {
}
export interface ColumnDescription {
type: ColumnType,
title: string,
getText?: (item: any) => string,
getMaybeOnClick?: (item: any) => () => void,
getTags?: (item: any) => any[],
getTagName?: (tag: any) => string,
getTagId?: (tag: any) => number,
getTagParent?: (tag: any) => any,
getTagOnClick?: (tag: any) => () => void,
}
export function RenderItem(props: {
columnDescription: ColumnDescription,
item: any
}) {
let { columnDescription: cd, item } = props;
import { MainWindowStateActions } from '../MainWindow';
import { newWindowReducer, WindowType } from '../windows/Windows';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import { songGetters } from '../../lib/songGetters';
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 IProps {
songs: any[],
songGetters: SongGetters,
mainDispatch: (action: any) => void,
}
export default function SongTable(props: IProps) {
const classes = makeStyles({
button: {
textTransform: "none",
@ -73,59 +34,6 @@ export function RenderItem(props: {
paddingLeft: '0',
textAlign: 'left',
},
})();
const TextCell = (props: any) => {
return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box
width="100%"
display="flex"
alignItems="center"
paddingLeft="16px"
>
{props.children}
</Box>
</Button>
</TableCell>;
}
switch (props.columnDescription.type) {
case ColumnType.Text:
const text = cd.getText && cd.getText(item) || "Unknown";
const onClick = cd.getMaybeOnClick && cd.getMaybeOnClick(item) || null;
return <TextCell align="left" _onClick={onClick}>{text}</TextCell>
break;
case ColumnType.Tags:
const tags: any[] = cd.getTags && cd.getTags(item) || [];
const fullTagNames: string[][] = getFullTagNames(
item,
cd.getTagName || (() => "Unknown"),
cd.getTagParent || (() => null),
cd.getTags || (() => []),
);
return <>{fullTagNames.map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
return <Box ml={0.5} mr={0.5}>
<Chip size="small"
label={fullTag}
onClick={cd.getTagOnClick && cd.getTagOnClick(tags[tags.length - 1])}
/>
</Box>;
})}</>;
break;
default:
throw 'Unknown column type';
}
}
export function ItemsTable(props: {
items: any[],
columns: ColumnDescription[],
}) {
const classes = makeStyles({
table: {
minWidth: 650,
},
@ -136,136 +44,125 @@ export function ItemsTable(props: {
<Table className={classes.table} aria-label="a dense table">
<TableHead>
<TableRow>
{props.columns.map((c: ColumnDescription) =>
<TableCell align="left">{c.title}</TableCell>)}
<TableCell align="left">Title</TableCell>
<TableCell align="left">Artist</TableCell>
<TableCell align="left">Album</TableCell>
<TableCell align="left">Tags</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.items.map((item: any, idx: number) => {
return <TableRow key={idx}>
{props.columns.map((c: ColumnDescription) =>
<RenderItem
columnDescription={c}
item={item}
/>)}
</TableRow>;
})}
</TableBody>
</Table>
</TableContainer>
);
{props.songs.map((song: any) => {
const title = props.songGetters.getTitle(song);
// TODO / FIXME: display artists and albums separately!
const artistNames = props.songGetters.getArtistNames(song);
const artist = stringifyList(artistNames);
const mainArtistId = props.songGetters.getArtistIds(song)[0];
const mainArtistName = artistNames[0];
const albumNames = props.songGetters.getAlbumNames(song);
const album = stringifyList(albumNames);
const mainAlbumName = albumNames[0];
const mainAlbumId = props.songGetters.getAlbumIds(song)[0];
const songId = props.songGetters.getId(song);
const tagIds = props.songGetters.getTagIds(song);
const onClickArtist = () => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><PersonIcon />{mainArtistName}</>,
artistId: mainArtistId,
metadata: null,
songGetters: songGetters,
songsByArtist: null,
},
tabReducer: newWindowReducer[WindowType.Artist],
tabType: WindowType.Artist,
})
}
export function TracksTable(props: {
tracks: QueryResponseTrackDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.tracks}
columns={[
{
title: 'Title', type: ColumnType.Text, getText: (i: QueryResponseTrackDetails) => i.name,
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
history.push('/track/' + i.id);
},
},
{
title: 'Artist', type: ColumnType.Text,
getText: (i: QueryResponseTrackDetails) => {
const artistNames = i.artists
.filter((a: Artist) => a.name)
.map((a: (Artist & Name)) => a.name);
return stringifyList(artistNames);
},
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
// TODO
const mainArtistId =
(i.artists.length > 0 && i.artists[0].id) || undefined;
history.push('/artist/' + mainArtistId || 'undefined');
},
},
{
title: 'Album', type: ColumnType.Text, getText: (i: QueryResponseTrackDetails) => i.album?.name || "Unknown",
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
history.push('/album/' + i.album?.id || 'undefined');
const onClickAlbum = () => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><AlbumIcon />{mainAlbumName}</>,
albumId: mainAlbumId,
metadata: null,
songGetters: songGetters,
songsOnAlbum: null,
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseTrackDetails) => i.tags,
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
}
]}
/>
tabReducer: newWindowReducer[WindowType.Album],
tabType: WindowType.Album,
})
}
export function ArtistsTable(props: {
artists: QueryResponseArtistDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.artists}
columns={[
{
title: 'Name', type: ColumnType.Text, getText: (i: QueryResponseArtistDetails) => i.name,
getMaybeOnClick: (i: QueryResponseArtistDetails) => () => {
history.push('/artist/' + i.id);
},
const onClickSong = () => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><AudiotrackIcon />{title}</>,
songId: songId,
metadata: null,
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseArtistDetails) => (i.tags || []),
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
}
]}
/>
tabReducer: newWindowReducer[WindowType.Song],
tabType: WindowType.Song,
})
}
export function AlbumsTable(props: {
albums: QueryResponseAlbumDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.albums}
columns={[
{
title: 'Name', type: ColumnType.Text, getText: (i: QueryResponseAlbumDetails) => i.name,
getMaybeOnClick: (i: QueryResponseAlbumDetails) => () => {
history.push('/album/' + i.id);
},
},
{
title: 'Artist', type: ColumnType.Text,
getText: (i: QueryResponseAlbumDetails) => {
const artistNames = (i.artists || [])
.filter((a: Artist) => a.name)
.map((a: Artist) => a.name || "Unknown");
return stringifyList(artistNames);
},
getMaybeOnClick: (i: QueryResponseAlbumDetails) => () => {
// TODO
const mainArtistId =
((i.artists || []).length > 0 && (i.artists || [])[0].id) || undefined;
history.push('/artist/' + mainArtistId || 'undefined');
},
const onClickTag = (id: number, name: string) => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><LocalOfferIcon />{name}</>,
tagId: id,
metadata: null,
songGetters: songGetters,
songsWithTag: null,
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseTrackDetails) => i.tags,
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
tabReducer: newWindowReducer[WindowType.Tag],
tabType: WindowType.Tag,
})
}
]}
const tags = props.songGetters.getTagNames(song).map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
return <Box ml={0.5} mr={0.5}>
<Chip size="small"
label={fullTag}
onClick={() => onClickTag(tagIds[i][tagIds[i].length - 1], fullTag)}
/>
</Box>
});
const TextCell = (props: any) => {
return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box
width="100%"
display="flex"
alignItems="center"
paddingLeft="16px"
>
{props.children}
</Box>
</Button>
</TableCell>;
}
return <TableRow key={title}>
<TextCell align="left" _onClick={onClickSong}>{title}</TextCell>
<TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell>
<TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell>
<TableCell padding="none" align="left" width="25%">
<Box display="flex" alignItems="center">
{tags}
</Box>
</TableCell>
</TableRow>
})}
</TableBody>
</Table>
</TableContainer>
);
}

@ -0,0 +1,231 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../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 { saveAlbumChanges } from '../../lib/saveChanges';
var _ = require('lodash');
export type AlbumMetadata = serverApi.AlbumDetails;
export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest;
export interface AlbumWindowState extends WindowState {
albumId: number,
metadata: AlbumMetadata | null,
pendingChanges: AlbumMetadataChanges | null,
songsOnAlbum: any[] | null,
songGetters: SongGetters,
}
export enum AlbumWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
Reload = "Reload",
}
export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
switch (action.type) {
case AlbumWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case AlbumWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case AlbumWindowStateActions.SetSongs:
return { ...state, songsOnAlbum: action.value }
case AlbumWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsOnAlbum: null }
default:
throw new Error("Unimplemented AlbumWindow state update.")
}
}
export interface IProps {
state: AlbumWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getAlbumMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.albumId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
albumOffset: 0,
albumLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let album = json.albums[0];
return album;
})();
}
export default function AlbumWindow(props: IProps) {
let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the album's metadata.
useEffect(() => {
getAlbumMetadata(props.state.albumId)
.then((m: AlbumMetadata) => {
props.dispatch({
type: AlbumWindowStateActions.SetMetadata,
value: m
});
})
}, [metadata?.name]);
// Effect to get the album's songs.
useEffect(() => {
if (props.state.songsOnAlbum) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.albumId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.albumId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: AlbumWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsOnAlbum]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: AlbumWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<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);
saveAlbumChanges(props.state.albumId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: AlbumWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<AlbumIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs in this album in your library:</Typography>
</Box>
{props.state.songsOnAlbum && <SongTable
songs={props.state.songsOnAlbum}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsOnAlbum && <CircularProgress />}
</Box>
</Box>
}

@ -0,0 +1,231 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core';
import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../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 { saveArtistChanges } from '../../lib/saveChanges';
var _ = require('lodash');
export type ArtistMetadata = serverApi.ArtistDetails;
export type ArtistMetadataChanges = serverApi.ModifyArtistRequest;
export interface ArtistWindowState extends WindowState {
artistId: number,
metadata: ArtistMetadata | null,
pendingChanges: ArtistMetadataChanges | null,
songsByArtist: any[] | null,
songGetters: SongGetters,
}
export enum ArtistWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
Reload = "Reload",
}
export function ArtistWindowReducer(state: ArtistWindowState, action: any) {
switch (action.type) {
case ArtistWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case ArtistWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case ArtistWindowStateActions.SetSongs:
return { ...state, songsByArtist: action.value }
case ArtistWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsByArtist: null }
default:
throw new Error("Unimplemented ArtistWindow state update.")
}
}
export interface IProps {
state: ArtistWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getArtistMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.artistId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
artistOffset: 0,
artistLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let artist = json.artists[0];
return artist;
})();
}
export default function ArtistWindow(props: IProps) {
let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the artist's metadata.
useEffect(() => {
getArtistMetadata(props.state.artistId)
.then((m: ArtistMetadata) => {
props.dispatch({
type: ArtistWindowStateActions.SetMetadata,
value: m
});
})
}, [metadata?.name]);
// Effect to get the artist's songs.
useEffect(() => {
if (props.state.songsByArtist) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.artistId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.artistId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: ArtistWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsByArtist]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: ArtistWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<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);
saveArtistChanges(props.state.artistId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: ArtistWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<PersonIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs by this artist in your library:</Typography>
</Box>
{props.state.songsByArtist && <SongTable
songs={props.state.songsByArtist}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsByArtist && <CircularProgress />}
</Box>
</Box>
}

@ -0,0 +1,146 @@
import React, { useEffect } from 'react';
import { createMuiTheme, Box, LinearProgress } from '@material-ui/core';
import { QueryElem, toApiQuery } from '../../lib/query/Query';
import QueryBuilder from '../querybuilder/QueryBuilder';
import * as serverApi from '../../api';
import SongTable from '../tables/ResultsTable';
import { songGetters } from '../../lib/songGetters';
import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters';
import { grey } from '@material-ui/core/colors';
import { WindowState } from './Windows';
var _ = require('lodash');
const darkTheme = createMuiTheme({
palette: {
type: 'dark',
primary: {
main: grey[100],
}
},
});
export interface ResultsForQuery {
for: QueryElem,
results: any[],
};
export interface QueryWindowState extends WindowState {
editingQuery: boolean,
query: QueryElem | null,
resultsForQuery: ResultsForQuery | null,
}
export enum QueryWindowStateActions {
SetQuery = "setQuery",
SetEditingQuery = "setEditingQuery",
SetResultsForQuery = "setResultsForQuery",
}
export function QueryWindowReducer(state: QueryWindowState, action: any) {
switch (action.type) {
case QueryWindowStateActions.SetQuery:
return { ...state, query: action.value }
case QueryWindowStateActions.SetEditingQuery:
return { ...state, editingQuery: action.value }
case QueryWindowStateActions.SetResultsForQuery:
return { ...state, resultsForQuery: action.value }
default:
throw new Error("Unimplemented QueryWindow state update.")
}
}
export interface IProps {
state: QueryWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export default function QueryWindow(props: IProps) {
let query = props.state.query;
let editing = props.state.editingQuery;
let resultsFor = props.state.resultsForQuery;
let setQuery = (q: QueryElem | null) => {
props.dispatch({ type: QueryWindowStateActions.SetQuery, value: q });
}
let setEditingQuery = (e: boolean) => {
props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e });
}
let setResultsForQuery = (r: ResultsForQuery | null) => {
props.dispatch({ type: QueryWindowStateActions.SetResultsForQuery, value: r });
}
const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query));
const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : [];
const doQuery = async (_query: QueryElem) => {
var q: serverApi.QueryRequest = {
query: toApiQuery(_query),
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
if (_.isEqual(query, _query)) {
setResultsForQuery({
for: _query,
results: json.songs,
})
}
})();
}
useEffect(() => {
if (query) {
doQuery(query);
} else {
setResultsForQuery(null);
}
}, [query]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
width="80%"
>
<QueryBuilder
query={query}
onChangeQuery={setQuery}
editing={editing}
onChangeEditing={setEditingQuery}
requestFunctions={{
getArtists: getArtists,
getSongTitles: getSongTitles,
getAlbums: getAlbums,
getTags: getTags,
}}
/>
</Box>
<Box
m={1}
width="80%"
>
<SongTable
songs={showResults}
songGetters={songGetters}
mainDispatch={props.mainDispatch}
/>
{loading && <LinearProgress />}
</Box>
</Box>
}

@ -0,0 +1,203 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, Button, CircularProgress } 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 { WindowState } from './Windows';
import { ArtistMetadata } from './ArtistWindow';
import { AlbumMetadata } from './AlbumWindow';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import { saveSongChanges } from '../../lib/saveChanges';
export type SongMetadata = serverApi.SongDetails;
export type SongMetadataChanges = serverApi.ModifySongRequest;
export interface SongWindowState extends WindowState {
songId: number,
metadata: SongMetadata | null,
pendingChanges: SongMetadataChanges | null,
}
export enum SongWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
Reload = "Reload",
}
export function SongWindowReducer(state: SongWindowState, action: any) {
switch (action.type) {
case SongWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case SongWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case SongWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null }
default:
throw new Error("Unimplemented SongWindow state update.")
}
}
export interface IProps {
state: SongWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getSongMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.songId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
songOffset: 0,
songLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let song = json.songs[0];
return song;
})();
}
export default function SongWindow(props: IProps) {
let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
useEffect(() => {
getSongMetadata(props.state.songId)
.then((m: SongMetadata) => {
props.dispatch({
type: SongWindowStateActions.SetMetadata,
value: m
});
})
}, [metadata?.title]);
const [editingTitle, setEditingTitle] = useState<string | null>(null);
const title = <Typography variant="h4"><EditableText
defaultValue={metadata?.title || "(Unknown title)"}
changedValue={pendingChanges?.title || null}
editingValue={editingTitle}
editingLabel="Title"
onChangeEditingValue={(v: string | null) => setEditingTitle(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.title = v }
else { delete newVal.title }
props.dispatch({
type: SongWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => {
return <Typography>
{artist.name}
</Typography>
});
const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => {
return <Typography>
{album.name}
</Typography>
});
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<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);
saveSongChanges(props.state.songId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: SongWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<AudiotrackIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{title}
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<PersonIcon />
<Box m={0.5}>
{artists}
</Box>
</Box>
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<AlbumIcon />
<Box m={0.5}>
{albums}
</Box>
</Box>
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
</Box>
}

@ -0,0 +1,258 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../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 { saveTagChanges } from '../../lib/saveChanges';
var _ = require('lodash');
export interface FullTagMetadata extends serverApi.TagDetails {
fullName: string[],
fullId: number[],
}
export type TagMetadata = FullTagMetadata;
export type TagMetadataChanges = serverApi.ModifyTagRequest;
export interface TagWindowState extends WindowState {
tagId: number,
metadata: TagMetadata | null,
pendingChanges: TagMetadataChanges | null,
songsWithTag: any[] | null,
songGetters: SongGetters,
}
export enum TagWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
Reload = "Reload",
}
export function TagWindowReducer(state: TagWindowState, action: any) {
switch (action.type) {
case TagWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case TagWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case TagWindowStateActions.SetSongs:
return { ...state, songsWithTag: action.value }
case TagWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsWithTag: null }
default:
throw new Error("Unimplemented TagWindow state update.")
}
}
export interface IProps {
state: TagWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getTagMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.tagId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
tagOffset: 0,
tagLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let tag = json.tags[0];
// Recursively fetch parent tags to build the full metadata.
if (tag.parentId) {
const parent = await getTagMetadata(tag.parentId);
tag.fullName = [...parent.fullName, tag.name];
tag.fullId = [...parent.fullId, tag.tagId];
} else {
tag.fullName = [tag.name];
tag.fullId = [tag.tagId];
}
return tag;
})();
}
export default function TagWindow(props: IProps) {
let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the tag's metadata.
useEffect(() => {
getTagMetadata(props.state.tagId)
.then((m: TagMetadata) => {
props.dispatch({
type: TagWindowStateActions.SetMetadata,
value: m
});
})
}, [metadata?.name]);
// Effect to get the tag's songs.
useEffect(() => {
if (props.state.songsWithTag) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.tagId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.tagId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: TagWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsWithTag]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: TagWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const fullName = <Box display="flex" alignItems="center">
{metadata?.fullName.map((n: string, i: number) => {
if (metadata?.fullName && i == metadata?.fullName.length - 1) {
return name;
} else if (i >= (metadata?.fullName.length || 0) - 1) {
return undefined;
} else {
return <Typography variant="h4">{n}&nbsp;/&nbsp;</Typography>
}
})}
</Box>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<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);
saveTagChanges(props.state.tagId, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({
type: TagWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<LocalOfferIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{fullName}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs with this tag in your library:</Typography>
</Box>
{props.state.songsWithTag && <SongTable
songs={props.state.songsWithTag}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsWithTag && <CircularProgress />}
</Box>
</Box>
}

@ -1,53 +1,40 @@
import React, { useReducer } from 'react';
import QueryWindow, { QueryWindowReducer } from "./query/QueryWindow";
import ArtistWindow, { ArtistWindowReducer } from "./artist/ArtistWindow";
import React from 'react';
import { QueryWindowReducer } from "./QueryWindow";
import { ArtistWindowReducer } from "./ArtistWindow";
import SearchIcon from '@material-ui/icons/Search';
import PersonIcon from '@material-ui/icons/Person';
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 TrackWindow, { TrackWindowReducer } from './track/TrackWindow';
import AlbumWindow, { AlbumWindowReducer } from './album/AlbumWindow';
import TagWindow, { TagWindowReducer } from './tag/TagWindow';
import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow';
import { RegisterWindowReducer } from './register/RegisterWindow';
import { LoginWindowReducer } from './login/LoginWindow';
import { SettingsWindowReducer } from './settings/SettingsWindow';
import { SongWindowReducer } from './SongWindow';
import { AlbumWindowReducer } from './AlbumWindow';
import { TagWindowReducer } from './TagWindow';
import { songGetters } from '../../lib/songGetters';
export enum WindowType {
Query = "Query",
Artist = "Artist",
Album = "Album",
Tag = "Tag",
Track = "Track",
ManageTags = "ManageTags",
Login = "Login",
Register = "Register",
Settings = "Settings",
ManageLinks = "ManageLinks",
Manage = "Manage",
Song = "Song",
}
export interface WindowState { }
export interface WindowState {
tabLabel: string,
}
export const newWindowReducer = {
[WindowType.Query]: QueryWindowReducer,
[WindowType.Artist]: ArtistWindowReducer,
[WindowType.Album]: AlbumWindowReducer,
[WindowType.Track]: TrackWindowReducer,
[WindowType.Song]: SongWindowReducer,
[WindowType.Tag]: TagWindowReducer,
[WindowType.ManageTags]: ManageTagsWindowReducer,
[WindowType.Login]: LoginWindowReducer,
[WindowType.Register]: RegisterWindowReducer,
[WindowType.Settings]: SettingsWindowReducer,
[WindowType.ManageLinks]: ManageTagsWindowReducer,
[WindowType.Manage]: ManageTagsWindowReducer,
}
export const newWindowState = {
[WindowType.Query]: () => {
return {
tabLabel: <><SearchIcon/>Query</>,
editingQuery: false,
query: null,
resultsForQuery: null,
@ -55,55 +42,40 @@ export const newWindowState = {
},
[WindowType.Artist]: () => {
return {
id: 1,
tabLabel: <><PersonIcon/>Artist 1</>,
artistId: 1,
metadata: null,
pendingChanges: null,
tracksByArtist: null,
songGetters: songGetters,
songsByArtist: null,
}
},
[WindowType.Album]: () => {
return {
id: 1,
tabLabel: <><AlbumIcon/>Album 1</>,
albumId: 1,
metadata: null,
pendingChanges: null,
tracksOnAlbum: null,
songGetters: songGetters,
songsOnAlbum: null,
}
},
[WindowType.Track]: () => {
[WindowType.Song]: () => {
return {
id: 1,
tabLabel: <><AudiotrackIcon/>Song 1</>,
songId: 1,
metadata: null,
pendingChanges: null,
}
},
[WindowType.Tag]: () => {
return {
id: 1,
tabLabel: <><LocalOfferIcon/>Tag 1</>,
tagId: 1,
metadata: null,
pendingChanges: null,
tracksWithTag: null,
songGetters: songGetters,
songsWithTag: null,
}
},
[WindowType.ManageTags]: () => {
return {
fetchedTags: null,
alert: null,
pendingChanges: [],
}
},
[WindowType.Login]: () => {
return {}
},
[WindowType.Register]: () => {
return {}
},
[WindowType.Settings]: () => {
return {}
},
[WindowType.ManageLinks]: () => {
return {}
},
[WindowType.Manage]: () => {
return {}
},
}

@ -1,204 +0,0 @@
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/api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable';
import { modifyAlbum, modifyTrack } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
import { Album, Name, Id, StoreLinks, AlbumRefs, Artist, Tag, Track, ResourceType } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type AlbumMetadata = serverApi.QueryResponseAlbumDetails;
export type AlbumMetadataChanges = serverApi.PatchAlbumRequest;
export interface AlbumWindowState extends WindowState {
id: number,
metadata: AlbumMetadata | null,
pendingChanges: AlbumMetadataChanges | null,
tracksOnAlbum: any[] | null,
}
export enum AlbumWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetTracks = "SetTracks",
Reload = "Reload",
}
export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
switch (action.type) {
case AlbumWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case AlbumWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case AlbumWindowStateActions.SetTracks:
return { ...state, tracksOnAlbum: action.value }
case AlbumWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, tracksOnAlbum: null }
default:
throw new Error("Unimplemented AlbumWindow state update.")
}
}
export async function getAlbumMetadata(id: number): Promise<AlbumMetadata> {
let result: any = await queryAlbums(
{
a: QueryLeafBy.AlbumId,
b: id,
leafOp: QueryLeafOp.Equals,
}, 0, 1, serverApi.QueryResponseType.Details
);
return result[0];
}
export default function AlbumWindow(props: {}) {
const { id } = useParams<{ id: string }>();
const [state, dispatch] = useReducer(AlbumWindowReducer, {
id: parseInt(id),
metadata: null,
pendingChanges: null,
tracksOnAlbum: null,
});
return <AlbumWindowControlled state={state} dispatch={dispatch} />
}
export function AlbumWindowControlled(props: {
state: AlbumWindowState,
dispatch: (action: any) => void,
}) {
let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state;
let { dispatch } = props;
let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the album's metadata.
useEffect(() => {
if (metadata === null) {
getAlbumMetadata(albumId)
.then((m: AlbumMetadata) => {
dispatch({
type: AlbumWindowStateActions.SetMetadata,
value: m
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}
}, [albumId, dispatch, metadata]);
// Effect to get the album's tracks.
useEffect(() => {
if (tracksOnAlbum) { return; }
(async () => {
const tracks = await queryTracks(
{
a: QueryLeafBy.AlbumId,
b: albumId,
leafOp: QueryLeafOp.Equals,
}, 0, -1, serverApi.QueryResponseType.Details
)
.catch((e: any) => { handleNotLoggedIn(auth, e) });
dispatch({
type: AlbumWindowStateActions.SetTracks,
value: tracks,
});
})();
}, [tracksOnAlbum, albumId, dispatch]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown album name)"}</Typography>
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>
});
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<AlbumIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks in this album in your library:</Typography>
</Box>
{props.state.tracksOnAlbum && <TracksTable tracks={props.state.tracksOnAlbum}/>}
{!props.state.tracksOnAlbum && <CircularProgress />}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchAlbumRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
artists: undefined,
tags: undefined,
trackIds: v.trackIds || v.tracks?.map(
(a: (Track & Id)) => { return a.id }
) || undefined,
artistIds: v.artistIds || v.artists?.map(
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyAlbum(albumId, v_modified)
.then(() => dispatch({
type: AlbumWindowStateActions.Reload
}))
}}
id={albumId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Album}
editStoreLinks={true}
/>}
</Box>
}

@ -1,209 +0,0 @@
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/api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable';
import { modifyAlbum, modifyArtist } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
import { Track, Id, Artist, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type ArtistMetadata = serverApi.QueryResponseArtistDetails;
export type ArtistMetadataChanges = serverApi.PatchArtistRequest;
export interface ArtistWindowState extends WindowState {
id: number,
metadata: ArtistMetadata | null,
pendingChanges: ArtistMetadataChanges | null,
tracksByArtist: any[] | null,
}
export enum ArtistWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetTracks = "SetTracks",
Reload = "Reload",
}
export function ArtistWindowReducer(state: ArtistWindowState, action: any) {
switch (action.type) {
case ArtistWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case ArtistWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case ArtistWindowStateActions.SetTracks:
return { ...state, tracksByArtist: action.value }
case ArtistWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, tracksByArtist: null }
default:
throw new Error("Unimplemented ArtistWindow state update.")
}
}
export interface IProps {
state: ArtistWindowState,
dispatch: (action: any) => void,
}
export async function getArtistMetadata(id: number): Promise<ArtistMetadata> {
let response: any = await queryArtists(
{
a: QueryLeafBy.ArtistId,
b: id,
leafOp: QueryLeafOp.Equals,
}, 0, 1, serverApi.QueryResponseType.Details
);
return response[0];
}
export default function ArtistWindow(props: {}) {
const { id } = useParams<{ id: string }>();
const [state, dispatch] = useReducer(ArtistWindowReducer, {
id: parseInt(id),
metadata: null,
pendingChanges: null,
tracksByArtist: null,
});
return <ArtistWindowControlled state={state} dispatch={dispatch} />
}
export function ArtistWindowControlled(props: {
state: ArtistWindowState,
dispatch: (action: any) => void,
}) {
let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state;
let { dispatch } = props;
let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the artist's metadata.
useEffect(() => {
if (metadata === null) {
getArtistMetadata(artistId)
.then((m: ArtistMetadata) => {
dispatch({
type: ArtistWindowStateActions.SetMetadata,
value: m
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}
}, [artistId, dispatch, metadata]);
// Effect to get the artist's tracks.
useEffect(() => {
if (tracksByArtist) { return; }
(async () => {
const tracks = await queryTracks(
{
a: QueryLeafBy.ArtistId,
b: artistId,
leafOp: QueryLeafOp.Equals,
}, 0, -1, serverApi.QueryResponseType.Details,
)
.catch((e: any) => { handleNotLoggedIn(auth, e) });
dispatch({
type: ArtistWindowStateActions.SetTracks,
value: tracks,
});
})();
}, [tracksByArtist, dispatch, artistId]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown artist)"}</Typography>
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>
});
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<PersonIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks by this artist in your library:</Typography>
</Box>
{props.state.tracksByArtist && <TracksTable tracks={props.state.tracksByArtist}/>}
{!props.state.tracksByArtist && <CircularProgress />}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchArtistRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
albums: undefined,
tags: undefined,
albumIds: v.albumIds || v.albums?.map(
(a: (Album & Id)) => { return a.id }
) || undefined,
trackIds: v.trackIds || v.tracks?.map(
(t: (Track & Id)) => { return t.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyArtist(artistId, v_modified)
.then(() => dispatch({
type: ArtistWindowStateActions.Reload
}))
}}
id={artistId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={true}
/>}
</Box>
}

@ -1,131 +0,0 @@
import React, { useReducer } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core";
import { useHistory, useLocation } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
export enum LoginStatus {
NoneSubmitted = 0,
Unsuccessful,
// Note: no "successful" status because that would lead to a redirect.
}
export interface LoginWindowState extends WindowState {
email: string,
password: string,
status: LoginStatus,
}
export enum LoginWindowStateActions {
SetEmail = "SetEmail",
SetPassword = "SetPassword",
SetStatus = "SetStatus",
}
export function LoginWindowReducer(state: LoginWindowState, action: any) {
switch (action.type) {
case LoginWindowStateActions.SetEmail:
return { ...state, email: action.value }
case LoginWindowStateActions.SetPassword:
return { ...state, password: action.value }
case LoginWindowStateActions.SetStatus:
return { ...state, status: action.value }
default:
throw new Error("Unimplemented LoginWindow state update.")
}
}
export default function LoginWindow(props: {}) {
const [state, dispatch] = useReducer(LoginWindowReducer, {
email: "",
password: "",
status: LoginStatus.NoneSubmitted,
});
return <LoginWindowControlled state={state} dispatch={dispatch} />
}
export function LoginWindowControlled(props: {
state: LoginWindowState,
dispatch: (action: any) => void,
}) {
let history: any = useHistory();
let location: any = useLocation();
let auth: Auth = useAuth();
let { from } = location.state || { from: { pathname: "/" } };
const onSubmit = (event: any) => {
event.preventDefault();
auth.signin(props.state.email, props.state.password)
.then(() => {
history.replace(from);
}).catch((e: any) => {
props.dispatch({
type: LoginWindowStateActions.SetStatus,
value: LoginStatus.Unsuccessful,
})
})
}
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="500px"
>
<Paper>
<Box p={3}>
<Typography variant="h5">Sign in</Typography>
<form noValidate onSubmit={onSubmit}>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="email"
label="Email"
name="email"
autoFocus
onInput={(e: any) => props.dispatch({
type: LoginWindowStateActions.SetEmail,
value: e.target.value
})}
/>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="password"
label="Password"
name="password"
type="password"
onInput={(e: any) => props.dispatch({
type: LoginWindowStateActions.SetPassword,
value: e.target.value
})}
/>
{props.state.status === LoginStatus.Unsuccessful && <Alert severity="error">
Login failed - Please check your credentials.
</Alert>
}
<Button
type="submit"
fullWidth
variant="outlined"
color="primary"
>Sign in</Button>
<Box display="flex" alignItems="center" mt={2}>
<Typography>Need an account?</Typography>
<Box flexGrow={1} ml={2}><Button
onClick={() => history.replace("/register")}
fullWidth
variant="outlined"
color="primary"
>Sign up</Button></Box>
</Box>
</form>
</Box>
</Paper>
</Box>
</Box>
}

@ -1,69 +0,0 @@
import React, { ReactFragment, useReducer, useState } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button, Tabs, Tab, Divider, IconButton } from "@material-ui/core";
import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import SaveIcon from '@material-ui/icons/Save';
import ManageLinksWindow from '../manage_links/ManageLinksWindow';
import ManageTagsWindow from '../manage_tags/ManageTagsWindow';
import ManageDataWindow from '../manage_data/ManageData';
export enum ManageWhat {
Tags = 0,
Links,
Data,
}
export default function ManageWindow(props: {
selectedWindow: ManageWhat,
}) {
let history = useHistory();
let NavButton = (props: {
label: string,
icon: ReactFragment,
selected: boolean,
onClick?: () => void,
}) => {
return <Button
style={{ textTransform: "none" }}
onClick={props.onClick}
variant={props.selected ? "outlined" : "text"}
>
<Box display="flex" flexDirection="column" alignItems="center">
{props.icon}{props.label}
</Box>
</Button>
}
return <Box display="flex" alignItems="top" height="100%" m={2}>
<Box display="flex" flexDirection="column" alignItems="center">
<NavButton
label="Tags"
icon={<LocalOfferIcon />}
selected={props.selectedWindow === ManageWhat.Tags}
onClick={() => history.push('/manage/tags')}
/>
<NavButton
label="Links"
icon={<OpenInNewIcon />}
selected={props.selectedWindow === ManageWhat.Links}
onClick={() => history.push('/manage/links')}
/>
<NavButton
label="Data"
icon={<SaveIcon />}
selected={props.selectedWindow === ManageWhat.Data}
onClick={() => history.push('/manage/data')}
/>
</Box>
{props.selectedWindow === ManageWhat.Tags && <ManageTagsWindow/>}
{props.selectedWindow === ManageWhat.Links && <ManageLinksWindow/>}
{props.selectedWindow === ManageWhat.Data && <ManageDataWindow/>}
</Box >
}

@ -1,124 +0,0 @@
import React, { ReactFragment, useReducer, useState } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button, Dialog, CircularProgress } from "@material-ui/core";
import SaveIcon from '@material-ui/icons/Save';
import GetAppIcon from '@material-ui/icons/GetApp';
import PublishIcon from '@material-ui/icons/Publish';
import DeleteForeverIcon from '@material-ui/icons/DeleteForever';
import { Alert } from '@material-ui/lab';
import * as serverApi from '../../../api/api';
import { getDBExportLink, importDB, wipeDB } from '../../../lib/backend/data';
import FileUploadButton from '../../common/FileUploadButton';
import { DBImportRequest } from '../../../api/api';
export interface ManageDataWindowState extends WindowState {
dummy: boolean
}
export enum ManageDataWindowActions {
SetDummy = "SetDummy",
}
export function ManageDataWindowReducer(state: ManageDataWindowState, action: any) {
switch (action.type) {
case ManageDataWindowActions.SetDummy: {
return state;
}
default:
throw new Error("Unimplemented ManageDataWindow state update.")
}
}
export default function ManageDataWindow(props: {}) {
const [state, dispatch] = useReducer(ManageDataWindowReducer, {
dummy: true,
});
return <ManageDataWindowControlled state={state} dispatch={dispatch} />
}
export function ManageDataWindowControlled(props: {
state: ManageDataWindowState,
dispatch: (action: any) => void,
}) {
let [alert, setAlert] = useState<ReactFragment>(<></>);
let handleImport = async (jsonData: DBImportRequest) => {
try {
setAlert(<CircularProgress/>)
await importDB(jsonData);
} catch (e) {
setAlert(<Alert severity="error">Failed to import database.</Alert>)
return;
}
setAlert(<Alert severity="success">Successfully imported database.</Alert>)
}
let handleWipe = async () => {
try {
setAlert(<CircularProgress/>)
await wipeDB();
} catch (e) {
setAlert(<Alert severity="error">Failed to wipe database.</Alert>)
return;
}
setAlert(<Alert severity="success">Successfully wiped database.</Alert>)
}
return <>
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<SaveIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<Typography variant="h4">Manage Data</Typography>
<Box mt={2} />
<Typography>
An exported database contains all your artists, albums, tracks and tags.<br />
It is represented as a JSON structure.
</Typography>
<Box mt={2} />
<Alert severity="warning">Imported items will not be merged with existing ones. If you wish to replace your database, wipe it first.</Alert>
{alert}
<Box display="flex" flexDirection="column" alignItems="left">
<Box mt={2}>
<a href={getDBExportLink()}>
<Button variant="outlined">
<GetAppIcon />
Export
</Button>
</a>
</Box>
<Box mt={2} >
<FileUploadButton variant="outlined"
onGetFile={(file: any) => {
const fileReader = new FileReader();
fileReader.readAsText(file, "UTF-8");
fileReader.onload = (e: any) => {
let json: DBImportRequest = JSON.parse(e.target.result);
handleImport(json);
}
}}>
<PublishIcon />
Import
</FileUploadButton>
</Box>
<Box mt={2} >
<Button variant="outlined"
onClick={handleWipe}
>
<DeleteForeverIcon />
Wipe
</Button>
</Box>
</Box>
</Box>
</Box>
</>
}

@ -1,436 +0,0 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Box, Button, Checkbox, createStyles, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, FormControlLabel, LinearProgress, List, ListItem, ListItemIcon, ListItemText, makeStyles, MenuItem, Paper, Select, Theme, Typography } from "@material-ui/core";
import StoreLinkIcon from '../../common/StoreLinkIcon';
import { $enum } from 'ts-enum-util';
import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations';
import { IntegrationWith, ImplIntegratesWith, IntegrationImpl, ResourceType, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { start } from 'repl';
import { QueryFor, QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot, simplify } from '../../../lib/query/Query';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries';
import asyncPool from "tiny-async-pool";
import { getTrack } from '../../../lib/backend/tracks';
import { getAlbum } from '../../../lib/backend/albums';
import { getArtist } from '../../../lib/backend/artists';
import { modifyAlbum, modifyArtist, modifyTrack } from '../../../lib/saveChanges';
import { QueryItemType } from '../query/QueryWindow';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
disabled: {
color: theme.palette.text.disabled,
},
})
);
enum BatchJobState {
Idle = 0,
Collecting,
Running,
Finished,
}
interface Task {
itemType: ResourceType,
itemId: number,
integrationId: number,
store: IntegrationWith,
}
interface BatchJobStatus {
state: BatchJobState,
numTasks: number,
tasksSuccess: number,
tasksFailed: number,
}
async function makeTasks(
integration: IntegrationState,
linkTracks: boolean,
linkArtists: boolean,
linkAlbums: boolean,
addTaskCb: (t: Task) => void,
) {
let whichElem: any = {
[ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists',
[ResourceType.Album]: 'albums',
}
let maybeStore = integration.integration.providesStoreLink();
if (!maybeStore) {
return;
}
let store = maybeStore as IntegrationWith;
let doForType = async (type: ResourceType) => {
let ids: number[] = ((await queryItems(
type,
queryNot({
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`,
}),
undefined,
undefined,
QueryResponseType.Ids
)) as any)[whichElem[type]];
ids.map((id: number) => {
addTaskCb({
itemType: type,
itemId: id,
integrationId: integration.id,
store: store,
});
})
}
var promises: Promise<any>[] = [];
if (linkTracks) { promises.push(doForType(ResourceType.Track)); }
if (linkArtists) { promises.push(doForType(ResourceType.Artist)); }
if (linkAlbums) { promises.push(doForType(ResourceType.Album)); }
await Promise.all(promises);
}
async function doLinking(
toLink: { integrationId: number, tracks: boolean, artists: boolean, albums: boolean }[],
setStatus: any,
integrations: IntegrationState[],
) {
// Start the collecting phase.
setStatus((s: any) => {
return {
state: BatchJobState.Collecting,
numTasks: 0,
tasksSuccess: 0,
tasksFailed: 0,
}
});
var tasks: Task[] = [];
let collectionPromises = toLink.map((v: any) => {
let { integrationId, tracks, artists, albums } = v;
let integration = integrations.find((i: IntegrationState) => i.id === integrationId);
if (!integration) { return; }
return makeTasks(
integration,
tracks,
artists,
albums,
(t: Task) => { tasks.push(t) }
);
})
await Promise.all(collectionPromises);
// Start the linking phase.
setStatus((status: BatchJobStatus) => {
return {
...status,
state: BatchJobState.Running,
numTasks: tasks.length
}
});
let makeJob: (t: Task) => Promise<void> = (t: Task) => {
let integration = integrations.find((i: IntegrationState) => i.id === t.integrationId);
return (async () => {
let onSuccess = () =>
setStatus((s: BatchJobStatus) => {
return {
...s,
tasksSuccess: s.tasksSuccess + 1,
}
});
let onFail = () =>
setStatus((s: BatchJobStatus) => {
return {
...s,
tasksFailed: s.tasksFailed + 1,
}
});
try {
if (integration === undefined) { return; }
let _integration = integration as IntegrationState;
let searchFuncs: any = {
[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 = {
[ResourceType.Track]: getTrack,
[ResourceType.Album]: getAlbum,
[ResourceType.Artist]: getArtist,
}
let queryFuncs: any = {
[ResourceType.Track]: (s: any) => `${s.name}` +
`${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}` +
`${s.albums && s.albums.length > 0 && ` ${s.albums[0].name}` || ''}`,
[ResourceType.Album]: (s: any) => `${s.name}` +
`${s.artists && s.artists.length > 0 && ` ${s.artists[0].name}` || ''}`,
[ResourceType.Artist]: (s: any) => `${s.name}`,
}
let modifyFuncs: any = {
[ResourceType.Track]: modifyTrack,
[ResourceType.Album]: modifyAlbum,
[ResourceType.Artist]: modifyArtist,
}
let item = await getFuncs[t.itemType](t.itemId);
let query = queryFuncs[t.itemType](item);
let candidates = await searchFuncs[t.itemType](
query,
1,
);
var success = false;
if (candidates && candidates.length && candidates.length > 0 && candidates[0].url) {
await modifyFuncs[t.itemType](
t.itemId,
{
mbApi_typename: t.itemType,
storeLinks: [...item.storeLinks, candidates[0].url],
}
)
success = true;
}
if (success) {
onSuccess();
} else {
onFail();
}
} catch (e) {
// Report fail
console.log("Error fetching candidates: ", e)
onFail();
}
})();
}
await asyncPool(8, tasks, makeJob);
// Finalize.
setStatus((status: BatchJobStatus) => {
return {
...status,
state: BatchJobState.Finished,
}
});
}
function ProgressDialog(props: {
open: boolean,
onClose: () => void,
status: BatchJobStatus,
}) {
let donePercent = ((props.status.tasksFailed + props.status.tasksSuccess) / (props.status.numTasks || 1)) * 100;
return <Dialog
open={props.open}
onClose={props.onClose}
>
{props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running &&
<DialogTitle>Batch linking in progress...</DialogTitle>}
{props.status.state === BatchJobState.Finished &&
<DialogTitle>Batch linking finished</DialogTitle>}
<DialogContent>
{props.status.state === BatchJobState.Collecting || props.status.state === BatchJobState.Running &&
<DialogContentText>
Closing or refreshing this page will interrupt and abort the process.
</DialogContentText>}
<Box minWidth="200px"><LinearProgress variant="determinate" color="secondary" value={donePercent} /></Box>
<Typography>
Found: {props.status.tasksSuccess}<br />
Failed: {props.status.tasksFailed}<br />
Total: {props.status.numTasks}<br />
</Typography>
</DialogContent>
{props.status.state === BatchJobState.Finished && <DialogActions>
<Button variant="contained" onClick={props.onClose}>Done</Button>
</DialogActions>}
</Dialog>
}
function ConfirmDialog(props: {
open: boolean
onConfirm: () => void,
onClose: () => void,
}) {
return <Dialog
open={props.open}
onClose={props.onClose}
>
<DialogTitle>Are you sure?</DialogTitle>
<DialogContent>
<DialogContentText>
This action is non-reversible.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="outlined">Cancel</Button>
<Button onClick={() => { props.onClose(); props.onConfirm(); }} variant="contained">Confirm</Button>
</DialogActions>
</Dialog>
}
export default function BatchLinkDialog(props: {
open: boolean,
onClose: () => void,
}) {
let integrations = useIntegrations();
let classes = useStyles();
let [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(false);
let [jobStatus, setJobStatus] = useState<BatchJobStatus>({
state: BatchJobState.Idle,
numTasks: 0,
tasksSuccess: 0,
tasksFailed: 0,
});
var compatibleIntegrations: Record<IntegrationWith, IntegrationState[]> = {
[IntegrationWith.GooglePlayMusic]: [],
[IntegrationWith.YoutubeMusic]: [],
[IntegrationWith.Spotify]: [],
};
$enum(IntegrationWith).getValues().forEach((store: IntegrationWith) => {
compatibleIntegrations[store] = Array.isArray(integrations.state) ?
integrations.state.filter((i: IntegrationState) => ImplIntegratesWith[i.properties.type] === store)
: [];
})
interface StoreSettings {
selectedIntegration: number | undefined, // Index into compatibleIntegrations
linkArtists: boolean,
linkTracks: boolean,
linkAlbums: boolean,
}
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,
linkTracks: false,
linkAlbums: false,
}
}
}, {})
);
let Text = (props: any) => {
return props.enabled ? <Typography>{props.children}</Typography> :
<Typography className={classes.disabled}>{props.children}</Typography>
}
return <>
<Dialog
maxWidth="md"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Box m={2}>
<Typography variant="h5">Batch linking</Typography>
<Typography>
Using this feature, links to external websites will automatically be added to existing items
in your music library. This happens by using any available integrations you have configured.<br />
Existing links are untouched.
</Typography>
<table style={{ borderSpacing: "20px" }}>
<tr>
<th><Typography><b>Service</b></Typography></th>
<th><Typography><b>Use Integration</b></Typography></th>
<td><Typography><b>Which items</b></Typography></td>
</tr>
{$enum(IntegrationWith).getValues().map((store: IntegrationWith) => {
let active = Boolean(compatibleIntegrations[store].length);
return <tr>
<td>
<Box display="flex" alignItems="center" flexDirection="column">
<StoreLinkIcon whichStore={store} />
<Text enabled={active}>{store}</Text>
</Box>
</td>
<td>
{!active && <Text enabled={active}>No integrations configured.</Text>}
{active && <Select fullWidth
value={storeSettings[store].selectedIntegration}
onChange={(e: any) => {
setStoreSettings({
...storeSettings,
[store]: {
...storeSettings[store],
selectedIntegration: e.target.value,
}
})
}}
>
{compatibleIntegrations[store].map((c: IntegrationState, idx: number) => {
return <MenuItem value={idx}>{c.properties.name}</MenuItem>
})}
</Select>}
</td>
<td>
<FormControlLabel control={
<Checkbox disabled={!active} checked={storeSettings[store].linkArtists}
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkArtists: e.target.checked } } })} />
} label={<Text enabled={active}>Artists</Text>} />
<FormControlLabel control={
<Checkbox disabled={!active} checked={storeSettings[store].linkAlbums}
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].linkTracks}
onChange={(e: any) => setStoreSettings((prev: any) => { return { ...prev, [store]: { ...prev[store], linkTracks: e.target.checked } } })} />
} label={<Text enabled={active}>Tracks</Text>} />
</td>
</tr>;
})}
</table>
<DialogActions>
<Button variant="outlined"
onClick={props.onClose}>Cancel</Button>
<Button variant="contained" color="secondary"
onClick={() => setConfirmDialogOpen(true)}>Start</Button>
</DialogActions>
</Box>
</Dialog>
<ConfirmDialog
open={confirmDialogOpen}
onClose={() => setConfirmDialogOpen(false)}
onConfirm={() => {
var toLink: any[] = [];
Object.keys(storeSettings).forEach((store: string) => {
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,
tracks: storeSettings[s].linkTracks,
artists: storeSettings[s].linkArtists,
albums: storeSettings[s].linkAlbums,
});
}
});
doLinking(
toLink,
setJobStatus,
integrations.state === "Loading" ?
[] : integrations.state,
)
}}
/>
<ProgressDialog
open={jobStatus.state === BatchJobState.Collecting || jobStatus.state === BatchJobState.Running || jobStatus.state === BatchJobState.Finished}
onClose={() => {
setJobStatus({
numTasks: 0,
tasksSuccess: 0,
tasksFailed: 0,
state: BatchJobState.Idle,
})
}}
status={jobStatus}
/>
</>
}

@ -1,134 +0,0 @@
import { Box, LinearProgress, Typography } from '@material-ui/core';
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { $enum } from 'ts-enum-util';
import { IntegrationWith, ResourceType, QueryElemProperty, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import StoreLinkIcon from '../../common/StoreLinkIcon';
var _ = require('lodash');
export default function LinksStatusWidget(props: {
}) {
type Counts = {
tracks: number | undefined,
albums: number | undefined,
artists: number | undefined,
};
let [totalCounts, setTotalCounts] = useState<Counts | undefined>(undefined);
let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({});
let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => {
let whichElem: any = {
[ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists',
[ResourceType.Album]: 'albums',
}
let r: any = await queryItems(
type,
{
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`,
},
undefined,
undefined,
QueryResponseType.Count
);
console.log("Result: ", type, store, r);
return r[whichElem[type]];
}
// Start retrieving total counts
useEffect(() => {
(async () => {
let counts: Counts = {
albums: await queryAlbums(undefined, undefined, undefined, QueryResponseType.Count) as number,
tracks: await queryTracks(undefined, undefined, undefined, QueryResponseType.Count) as number,
artists: await queryArtists(undefined, undefined, undefined, QueryResponseType.Count) as number,
}
console.log("Got total counts: ", counts)
setTotalCounts(counts);
}
)();
}, []);
// Start retrieving counts per store
useEffect(() => {
(async () => {
let promises = $enum(IntegrationWith).getValues().map((s: IntegrationWith) => {
let tracksPromise: 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([tracksPromise, albumsPromise, artistsPromise]).then(
(r: any[]) => {
console.log("Grouped: ", r)
setLinkedCounts((prev: Record<string, Counts>) => {
return {
...prev,
[s]: {
tracks: r[0],
artists: r[2],
albums: r[1],
}
}
});
}
)
console.log(s);
return updatePromise;
})
return Promise.all(promises);
}
)();
}, [setLinkedCounts]);
let storeReady = (s: IntegrationWith) => {
return s in linkedCounts;
}
return <Box display="flex" flexDirection="column" alignItems="left">
<table>
{$enum(IntegrationWith).getValues().map((s: IntegrationWith) => {
if (!totalCounts) { return <></>; }
if (!storeReady(s)) { return <></>; }
let tot = totalCounts;
let lin = linkedCounts[s];
let perc = {
tracks: Math.round((lin.tracks || 0) / (tot.tracks || 1) * 100),
artists: Math.round((lin.artists || 0) / (tot.artists || 1) * 100),
albums: Math.round((lin.albums || 0) / (tot.albums || 1) * 100),
}
return <>
{totalCounts && storeReady(s) && <>
<tr>
<td rowSpan={3}>
<Box display="flex" flexDirection="column" alignItems="center">
<StoreLinkIcon whichStore={s} />
<Typography>{s}</Typography>
</Box>
</td>
<td><Typography>Linked artists:</Typography></td>
<td><Box minWidth="200px"><LinearProgress variant="determinate" color="secondary" value={perc.artists} /></Box></td>
<td><Typography>{lin.artists} / {tot.artists}</Typography></td>
</tr>
<tr>
<td><Typography>Linked albums:</Typography></td>
<td><Box minWidth="200px"><LinearProgress variant="determinate" color="secondary" value={perc.albums} /></Box></td>
<td><Typography>{lin.albums} / {tot.albums}</Typography></td>
</tr>
<tr>
<td><Typography>Linked tracks:</Typography></td>
<td><Box minWidth="200px"><LinearProgress variant="determinate" color="secondary" value={perc.tracks} /></Box></td>
<td><Typography>{lin.tracks} / {tot.tracks}</Typography></td>
</tr>
<tr><td colSpan={4}>&nbsp;</td></tr>
</>
}
</>;
})}
</table>
</Box >
}

@ -1,81 +0,0 @@
import React, { useReducer, useState } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core";
import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import LinksStatusWidget from './LinksStatusWidget';
import BatchLinkDialog from './BatchLinkDialog';
export interface ManageLinksWindowState extends WindowState {
dummy: boolean
}
export enum ManageLinksWindowActions {
SetDummy = "SetDummy",
}
export function ManageLinksWindowReducer(state: ManageLinksWindowState, action: any) {
switch (action.type) {
case ManageLinksWindowActions.SetDummy: {
return state;
}
default:
throw new Error("Unimplemented ManageLinksWindow state update.")
}
}
export default function ManageLinksWindow(props: {}) {
const [state, dispatch] = useReducer(ManageLinksWindowReducer, {
dummy: true,
});
return <ManageLinksWindowControlled state={state} dispatch={dispatch} />
}
export function ManageLinksWindowControlled(props: {
state: ManageLinksWindowState,
dispatch: (action: any) => void,
}) {
let [batchDialogOpen, setBatchDialogOpen] = useState<boolean>(false);
return <>
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<OpenInNewIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<Typography variant="h4">Manage Links</Typography>
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<LinksStatusWidget />
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<Button variant="outlined"
onClick={() => { setBatchDialogOpen(true); }}>
Batch linking...
</Button>
</Box>
</Box>
<BatchLinkDialog
onClose={() => { setBatchDialogOpen(false); }}
open={batchDialogOpen}
/>
</>
}

@ -1,97 +0,0 @@
import React from 'react';
import { Menu, MenuItem } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item";
import MenuEditText from '../../common/MenuEditText';
export function PickTag(props: {
tags: any[]
open: boolean
root: boolean
onPick: (v: string | null) => void
}) {
return <>
{props.root && <MenuItem onClick={() => props.onPick(null)}>/</MenuItem>}
{props.tags.map((tag: any) => {
if ('children' in tag && tag.children.length > 0) {
return <NestedMenuItem
parentMenuOpen={props.open}
label={tag.name}
onClick={() => props.onPick(tag.tagId.toString())}
>
<PickTag tags={tag.children} open={props.open} root={false} onPick={props.onPick} />
</NestedMenuItem>
}
return <MenuItem onClick={() => props.onPick(tag.tagId.toString())}>{tag.name}</MenuItem>
})
}</>
}
export default function ManageTagMenu(props: {
position: null | number[],
open: boolean,
onClose: () => void,
onRename: (s: string) => void,
onDelete: () => void,
onMove: (to: string | null) => void,
onMergeInto: (to: string) => void,
onOpenTag: () => void,
tag: any,
changedTags: any[], // Tags organized hierarchically with "children" fields
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<MenuItem
onClick={() => {
props.onClose();
props.onOpenTag();
}}
>Browse</MenuItem>
<MenuItem
onClick={() => {
props.onClose();
props.onDelete();
}}
>Delete</MenuItem>
<NestedMenuItem
parentMenuOpen={props.open}
label="Rename"
>
<MenuEditText
label="New name"
onSubmit={(s: string) => {
props.onClose();
props.onRename(s);
}}
/>
</NestedMenuItem>
<NestedMenuItem
parentMenuOpen={props.open}
label="Move to"
>
<PickTag tags={props.changedTags} open={props.open} root={true} onPick={(v: string | null) => {
props.onClose();
props.onMove(v);
}} />
</NestedMenuItem>
<NestedMenuItem
parentMenuOpen={props.open}
label="Merge into"
>
<PickTag tags={props.changedTags} open={props.open} root={false} onPick={(v: string | null) => {
if(v === null) { return; }
props.onClose();
props.onMergeInto(v);
}} />
</NestedMenuItem>
</Menu>
}

@ -1,505 +0,0 @@
import React, { useEffect, useState, ReactFragment, useReducer } from 'react';
import { WindowState } from '../Windows';
import { Box, Typography, Chip, IconButton, useTheme, Button } from '@material-ui/core';
import LoyaltyIcon from '@material-ui/icons/Loyalty';
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import ManageTagMenu from './ManageTagMenu';
import ControlTagChanges, { TagChange, TagChangeType, submitTagChanges } from './TagChange';
import { queryTags } from '../../../lib/backend/queries';
import NewTagMenu from './NewTagMenu';
import { v4 as genUuid } from 'uuid';
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/api';
import { Id, QueryResponseTagDetails, Tag, Name } from '../../../api/api';
var _ = require('lodash');
export interface ManageTagsWindowState extends WindowState {
// Tags are indexed by a string ID. This can be a stringified MuDBase ID integer,
// or a UID for tags which only exist in the front-end and haven't been committed
// to the database.
fetchedTags: Record<string, ManagedTag> | null,
pendingChanges: TagChange[],
alert: ReactFragment | null, // For notifications such as errors
}
export enum ManageTagsWindowActions {
SetFetchedTags = "SetFetchedTags",
SetPendingChanges = "SetPendingChanges",
Reset = "Reset",
SetAlert = "SetAlert",
}
export function ManageTagsWindowReducer(state: ManageTagsWindowState, action: any) {
switch (action.type) {
case ManageTagsWindowActions.SetFetchedTags:
return {
...state,
fetchedTags: action.value,
}
case ManageTagsWindowActions.SetPendingChanges:
return {
...state,
pendingChanges: action.value,
}
case ManageTagsWindowActions.Reset:
return {
...state,
pendingChanges: [],
fetchedTags: null,
alert: null,
}
case ManageTagsWindowActions.SetAlert:
return {
...state,
alert: action.value,
}
default:
throw new Error("Unimplemented ManageTagsWindow state update.")
}
}
export function organiseTags(allTags: Record<string, ManagedTag>, fromId: string | null):
ManagedTag[] {
const base = Object.values(allTags).filter((tag: ManagedTag) => {
var par = ("proposedParent" in tag) ? tag.proposedParent : tag.parentId;
return (fromId === null && !par) ||
(par && par === fromId)
});
return base.map((tag: ManagedTag) => {
return {
...tag,
children: organiseTags(allTags, tag.strTagId),
}
});
}
// Work with strings so we can have temporary randomized IDs.
type ManagedTag = (Tag & Name & {
id?: number,
strTagId: string,
strParentId: string | null,
strChildIds: string[],
proposeDelete?: boolean,
proposedName?: string,
proposedParent?: string | null,
proposedMergeInto?: string,
isNewTag?: boolean,
children?: ManagedTag[],
});
export async function getAllTags(): Promise<Record<string, ManagedTag>> {
return (async () => {
var retval: Record<string, ManagedTag> = {};
const tags: QueryResponseTagDetails[] = await queryTags(
undefined, 0, -1, serverApi.QueryResponseType.Details,
) as QueryResponseTagDetails[];
tags.forEach((tag: QueryResponseTagDetails) => {
retval[tag.id.toString()] = {
...tag,
strTagId: tag.id.toString(),
strParentId: tag.parentId?.toString() || null,
strChildIds: tags
.filter((t: QueryResponseTagDetails) => t.parentId == tag.id)
.map((t: QueryResponseTagDetails) => t.id.toString() )
}
});
return retval;
})();
}
export function ExpandArrow(props: {
expanded: boolean,
onSetExpanded: (v: boolean) => void,
}) {
return props.expanded ?
<IconButton size="small" onClick={() => props.onSetExpanded(false)}><ArrowDropDownIcon /></IconButton> :
<IconButton size="small" onClick={() => props.onSetExpanded(true)}><ArrowRightIcon /></IconButton>;
}
export function CreateTagButton(props: any) {
return <Box display="flex">
<Box visibility='hidden'>
<ExpandArrow expanded={false} onSetExpanded={(v: boolean) => { }} />
</Box>
<Button style={{ textTransform: 'none' }} variant="outlined" {...props}>New Tag...</Button>
</Box>
}
export function SingleTag(props: {
tag: ManagedTag,
prependElems: any[],
dispatch: (action: any) => void,
state: ManageTagsWindowState,
changedTags: ManagedTag[],
}) {
const tag = props.tag;
const hasChildren = tag.children && tag.children.length > 0;
const [menuPos, setMenuPos] = React.useState<null | number[]>(null);
const [expanded, setExpanded] = useState<boolean>(true);
const theme = useTheme();
const history = useHistory();
const onOpenMenu = (e: any) => {
setMenuPos([e.clientX, e.clientY])
};
const onCloseMenu = () => {
setMenuPos(null);
};
var tagLabel: any = tag.name;
if ("proposedName" in tag) {
tagLabel = <><del style={{ color: theme.palette.text.secondary }}>{tag.name}</del>{tag.proposedName}</>;
} else if ("proposeDelete" in tag && tag.proposeDelete) {
tagLabel = <><del style={{ color: theme.palette.text.secondary }}>{tag.name}</del></>;
}
const TagChip = (props: any) => <Box
style={{ opacity: props.transparent ? 0.5 : 1.0 }}
>
<Chip
size="small"
label={props.label}
onClick={onOpenMenu}
/>
</Box>;
return <>
<Box display="flex" alignItems="center">
<Box visibility={hasChildren ? 'visible' : 'hidden'}>
<ExpandArrow expanded={expanded} onSetExpanded={setExpanded} />
</Box>
{props.prependElems}
<TagChip transparent={tag.proposeDelete} label={tagLabel} />
</Box>
{expanded && tag.children && tag.children
.sort((a: ManagedTag, b: ManagedTag) => a.name.localeCompare(b.name))
.map((child: ManagedTag) => <SingleTag
tag={child}
prependElems={[...props.prependElems,
<TagChip transparent={true} label={tagLabel} />,
<Typography variant="h5">/</Typography>]}
dispatch={props.dispatch}
state={props.state}
changedTags={props.changedTags}
/>)}
<ManageTagMenu
position={menuPos}
open={menuPos !== null}
onClose={onCloseMenu}
onOpenTag={() => {
history.push('/tag/' + tag.strTagId);
}}
onRename={(s: string) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.Rename,
name: s,
id: tag.strTagId,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onDelete={() => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.Delete,
id: tag.strTagId,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onMove={(to: string | null) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.MoveTo,
id: tag.strTagId,
parent: to,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onMergeInto={(into: string) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.MergeTo,
id: tag.strTagId,
into: into,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
tag={tag}
changedTags={props.changedTags}
/>
</>
}
function annotateTagsWithChanges(tags: Record<string, ManagedTag>, changes: TagChange[])
: Record<string, ManagedTag> {
var retval: Record<string, ManagedTag> = _.cloneDeep(tags);
const applyDelete = (id: string) => {
retval[id].proposeDelete = true;
Object.values(tags).filter((t: ManagedTag) => t.strParentId === id)
.forEach((child: ManagedTag) => applyDelete(child.strTagId));
}
changes.forEach((change: TagChange) => {
switch (change.type) {
case TagChangeType.Rename:
retval[change.id].proposedName = change.name;
break;
case TagChangeType.Delete:
applyDelete(change.id);
break;
case TagChangeType.MoveTo:
retval[change.id].proposedParent = change.parent;
break;
case TagChangeType.MergeTo:
retval[change.id].proposedMergeInto = change.into;
break;
case TagChangeType.Create:
if (!change.name) {
throw new Error("Trying to create a tag without a name");
}
retval[change.id] = {
mbApi_typename: 'tag',
isNewTag: true,
name: change.name,
strTagId: change.id,
strParentId: change.parent || null,
strChildIds: [],
}
if (change.parent) {
retval[change.parent].strChildIds =
[...retval[change.parent].strChildIds, change.id]
}
break;
default:
throw new Error("Unimplemented tag change")
}
})
return retval;
}
function applyTagsChanges(tags: Record<string, ManagedTag>, changes: TagChange[]) {
var retval = _.cloneDeep(tags);
const applyDelete = (id: string) => {
Object.values(tags).filter((t: ManagedTag) => t.strParentId === id)
.forEach((child: ManagedTag) => applyDelete(child.strTagId));
delete retval[id].proposeDelete;
}
changes.forEach((change: TagChange) => {
switch (change.type) {
case TagChangeType.Rename:
retval[change.id].name = change.name;
break;
case TagChangeType.Delete:
applyDelete(change.id);
break;
case TagChangeType.MoveTo:
retval[change.id].parentId = change.parent;
if (change.parent === null) { delete retval[change.id].parentId; }
break;
case TagChangeType.MergeTo:
applyDelete(change.id);
break;
case TagChangeType.Create:
retval[change.id] = {
name: change.name,
tagId: change.id,
parentId: change.parent,
isNewTag: true,
}
if (change.parent) {
retval[change.parent].childIds =
[...retval[change.parent].childIds, change.id]
}
break;
default:
throw new Error("Unimplemented tag change")
}
})
return retval;
}
export default function ManageTagsWindow(props: {}) {
const [state, dispatch] = useReducer(ManageTagsWindowReducer, {
fetchedTags: null,
alert: null,
pendingChanges: [],
});
return <ManageTagsWindowControlled state={state} dispatch={dispatch} />
}
export function ManageTagsWindowControlled(props: {
state: ManageTagsWindowState,
dispatch: (action: any) => void,
}) {
const [newTagMenuPos, setNewTagMenuPos] = React.useState<null | number[]>(null);
let { fetchedTags } = props.state;
let { dispatch } = props;
let auth = useAuth();
const onOpenNewTagMenu = (e: any) => {
setNewTagMenuPos([e.clientX, e.clientY])
};
const onCloseNewTagMenu = () => {
setNewTagMenuPos(null);
};
useEffect(() => {
if (fetchedTags !== null) {
return;
}
(async () => {
const allTags = await getAllTags();
// We have the tags in list form. Now, we want to organize
// them hierarchically by giving each tag a "children" prop.
dispatch({
type: ManageTagsWindowActions.SetFetchedTags,
value: allTags,
});
})();
}, [fetchedTags, dispatch]);
const tagsWithChanges = annotateTagsWithChanges(props.state.fetchedTags || {}, props.state.pendingChanges || [])
const changedTags = organiseTags(
applyTagsChanges(props.state.fetchedTags || {}, props.state.pendingChanges || []),
null);
const tags = organiseTags(tagsWithChanges, null);
return <>
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<LoyaltyIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<Typography variant="h4">Manage Tags</Typography>
</Box>
{props.state.pendingChanges.length > 0 && <Box
m={1}
mt={4}
width="80%"
>
<ControlTagChanges
changes={props.state.pendingChanges}
onDiscard={() => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [],
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onSave={() => {
submitTagChanges(props.state.pendingChanges).then(() => {
props.dispatch({
type: ManageTagsWindowActions.Reset
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
.catch((e: Error) => {
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: <Alert severity="error">Failed to save changes: {e.message}</Alert>,
})
})
}}
getTagDetails={(id: string) => tagsWithChanges[id]}
/>
</Box>}
{props.state.alert && <Box
m={1}
mt={4}
width="80%"
>{props.state.alert}</Box>}
<Box
m={1}
mt={4}
width="80%"
>
{tags && tags.length && tags
.sort((a: ManagedTag, b: ManagedTag) => a.name.localeCompare(b.name))
.map((tag: ManagedTag) => {
return <SingleTag
tag={tag}
prependElems={[]}
dispatch={props.dispatch}
state={props.state}
changedTags={changedTags}
/>;
})}
<Box mt={3}><CreateTagButton onClick={(e: any) => { onOpenNewTagMenu(e) }} /></Box>
</Box>
</Box>
<NewTagMenu
position={newTagMenuPos}
open={newTagMenuPos !== null}
onCreate={(name: string, parentId: string | null) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.Create,
id: genUuid(),
parent: parentId,
name: name,
}
]
})
}}
onClose={onCloseNewTagMenu}
changedTags={changedTags}
/>
</>
}

@ -1,54 +0,0 @@
import React from 'react';
import { Menu } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item";
import MenuEditText from '../../common/MenuEditText';
export function PickCreateTag(props: {
tags: any[],
open: boolean,
parentId: string | null,
onCreate: (name: string, parentId: string | null) => void,
}) {
return <>
<MenuEditText
label="Name"
onSubmit={(s: string) => {
props.onCreate(s, props.parentId);
}}
/>
{props.tags.map((tag: any) => {
return <NestedMenuItem
parentMenuOpen={props.open}
label={tag.name}
>
<PickCreateTag tags={tag.children} open={props.open} parentId={tag.tagId} onCreate={props.onCreate} />
</NestedMenuItem>
})}
</>
}
export default function NewTagMenu(props: {
position: null | number[],
open: boolean,
onCreate: (name: string, parentId: string | null) => void,
onClose: () => void,
changedTags: any[], // Tags organized hierarchically with "children" fields
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<PickCreateTag tags={props.changedTags} open={props.open} parentId={null} onCreate={(n: string, v: string | null) => {
props.onClose();
props.onCreate(n, v);
}} />
</Menu>
}

@ -1,148 +0,0 @@
import React from 'react';
import { Typography, Chip, CircularProgress, Box, Paper } from '@material-ui/core';
import DiscardChangesButton from '../../common/DiscardChangesButton';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import { createTag, modifyTag, deleteTag, mergeTag } from '../../../lib/backend/tags';
export enum TagChangeType {
Delete = "Delete",
Create = "Create",
MoveTo = "MoveTo",
MergeTo = "MergeTo",
Rename = "Rename",
}
export interface TagChange {
type: TagChangeType,
id: string, // Stringified integer == MuDBase ID. Other string == not yet committed to DB.
parent?: string | null, // Stringified integer == MuDBase ID. Other string == not yet committed to DB.
// null refers to the tags root.
name?: string,
into?: string, // Used for storing the tag ID to merge into, if applicable. As in the other ID fields.
}
export async function submitTagChanges(changes: TagChange[]) {
// Upon entering this function, some tags have a real numeric MuDBase ID (stringified),
// while others have a UUID string which is a placeholder until the tag is created.
// While applying the changes, UUIDs will be replaced by real numeric IDs.
// Therefore we maintain a lookup table for mapping the old to the new.
var id_lookup: Record<string, number> = {}
const getId = (id_string: string) => {
return (isNaN(Number(id_string))) ?
id_lookup[id_string] : Number(id_string);
}
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) : 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,
});
id_lookup[change.id] = id;
break;
case TagChangeType.MoveTo:
if (!numericId) { throw new Error("Cannot modify tag with no numeric ID"); }
await modifyTag(
numericId,
{
mbApi_typename: 'tag',
parentId: parentId,
})
break;
case TagChangeType.Rename:
if (!numericId) { throw new Error("Cannot modify tag with no numeric ID"); }
await modifyTag(
numericId,
{
mbApi_typename: 'tag',
name: change.name,
})
break;
case TagChangeType.Delete:
if (!numericId) { throw new Error("Cannot delete tag with no numeric ID"); }
await deleteTag(numericId)
break;
case TagChangeType.MergeTo:
if (!numericId) { throw new Error("Cannot merge tag with no numeric ID"); }
if (!intoId) { throw new Error("Cannot merge tag into tag with no numeric ID"); }
await mergeTag(numericId, intoId);
break;
default:
throw new Error("Unimplemented tag change");
}
}
}
export function TagChangeDisplay(props: {
change: TagChange,
getTagDetails: (id: string) => any,
}) {
const tag = props.getTagDetails(props.change.id);
const oldParent = tag.parentId ? props.getTagDetails(tag.parentId) : null;
const newParent = props.change.parent ? props.getTagDetails(props.change.parent) : null;
const MakeTag = (props: { name: string }) => <Chip label={props.name} />
const MainTag = tag ?
<MakeTag name={tag.name} /> :
<CircularProgress />;
switch (props.change.type) {
case TagChangeType.Delete:
return <Typography>Delete {MainTag}</Typography>
case TagChangeType.Rename:
const NewTag = tag ?
<MakeTag name={props.change.name || "unknown"} /> :
<CircularProgress />;
return <Typography>Rename {MainTag} to {NewTag}</Typography>
case TagChangeType.MoveTo:
const OldParent = oldParent !== undefined ?
<MakeTag name={oldParent ? oldParent.name : "/"} /> :
<CircularProgress />;
const NewParent = newParent !== undefined ?
<MakeTag name={newParent ? newParent.name : "/"} /> :
<CircularProgress />;
return <Typography>Move {MainTag} from {OldParent} to {NewParent}</Typography>
case TagChangeType.MergeTo:
const intoTag = props.getTagDetails(props.change.into || "unknown");
const IntoTag = <MakeTag name={intoTag.name} />
return <Typography>Merge {MainTag} into {IntoTag}</Typography>
case TagChangeType.Create:
return props.change.parent ?
<Typography>Create {MainTag} under <MakeTag name={newParent.name} /></Typography> :
<Typography>Create {MainTag}</Typography>
default:
throw new Error("Unhandled tag change type")
}
}
export default function ControlTagChanges(props: {
changes: TagChange[],
onSave: () => void,
onDiscard: () => void,
getTagDetails: (id: string) => any,
}) {
return <Box display="flex"><Paper style={{ padding: 10, minWidth: 0 }}>
<Typography variant="h5">Pending changes</Typography>
<Box mt={2}>
{props.changes.map((change: any) =>
<Box display="flex">
<Typography>-&nbsp;</Typography>
<TagChangeDisplay change={change} getTagDetails={props.getTagDetails} />
</Box>
)}
</Box>
<Box mt={2} display="flex">
<Box m={1}><SubmitChangesButton onClick={props.onSave}>Save Changes</SubmitChangesButton></Box>
<Box m={1}><DiscardChangesButton onClick={props.onDiscard}>Discard Changes</DiscardChangesButton></Box>
</Box>
</Paper></Box>
}

@ -1,290 +0,0 @@
import React, { useEffect, useReducer, useCallback } from 'react';
import { Box, LinearProgress, Typography } from '@material-ui/core';
import { QueryElem, QueryLeafBy, QueryLeafElem, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder, { QueryBuilderTag } from '../../querybuilder/QueryBuilder';
import { AlbumsTable, ArtistsTable, ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable';
import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries';
import { WindowState } from '../Windows';
import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails, Artist, Name } from '../../../api/api';
import { ServerStreamResponseOptions } from 'http2';
import { TrackChangesSharp } from '@material-ui/icons';
import { v4 as genUuid } from 'uuid';
import stringifyList from '../../../lib/stringifyList';
var _ = require('lodash');
export enum QueryItemType {
Artists,
Tracks,
Albums,
Tags,
};
export interface ResultsForQuery {
kind: QueryItemType,
results: (
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]
),
}
export interface QueryWindowState extends WindowState {
editingQuery: boolean, // Is the editor in "edit mode"
query: QueryElem | null, // The actual on-screen query
includeTypes: QueryItemType[], // which item types do we actually request results for?
// Whenever queries change, new requests are fired to the server.
// Each request gets a unique id hash.
// In this results record, we store the query IDs which
// we want to show results for.
resultsForQueries: Record<string, ResultsForQuery | null>;
}
export enum QueryWindowStateActions {
FiredNewQueries = "firedNewQueries",
SetEditingQuery = "setEditingQuery",
ReceivedResult = "receivedResult",
}
async function getArtistNames(filter: string) {
const artists: any = await queryArtists(
filter.length > 0 ? {
a: QueryLeafBy.ArtistName,
b: '%' + filter + '%',
leafOp: QueryLeafOp.Like
} : undefined,
0, -1, QueryResponseType.Details
);
return [...(new Set([...(artists.map((a: any) => a.name))]))];
}
async function getAlbumNames(filter: string) {
const albums: any = await queryAlbums(
filter.length > 0 ? {
a: QueryLeafBy.AlbumName,
b: '%' + filter + '%',
leafOp: QueryLeafOp.Like
} : undefined,
0, -1, QueryResponseType.Details
);
return [...(new Set([...(albums.map((a: any) => a.name))]))];
}
async function getTrackNames(filter: string) {
const tracks: any = await queryTracks(
filter.length > 0 ? {
a: QueryLeafBy.TrackName,
b: '%' + filter + '%',
leafOp: QueryLeafOp.Like
} : undefined,
0, -1, QueryResponseType.Details
);
return [...(new Set([...(tracks.map((s: any) => s.name))]))];
}
async function getTagItems(): Promise<QueryBuilderTag[]> {
let tags: QueryResponseTagDetails[] = (await queryTags(
undefined,
0, -1, QueryResponseType.Details
)) as QueryResponseTagDetails[];
// We need to resolve the child ids.
let tags_with_children : QueryBuilderTag[] = tags.map((t: QueryResponseTagDetails) => {
return {
...t,
childIds: tags.filter((t2: QueryResponseTagDetails) => t2.parentId === t.id)
.map((t2: QueryResponseTagDetails) => t2.id)
}
})
return tags_with_children;
}
export interface FireNewQueriesData {
query: QueryElem | null,
includeTypes: QueryItemType[],
resultIds: string[],
}
export interface ReceivedResultData {
result: ResultsForQuery,
id: string,
}
export function QueryWindowReducer(state: QueryWindowState, action: any) {
switch (action.type) {
case QueryWindowStateActions.ReceivedResult:
var arr = action.value as ReceivedResultData;
if (Object.keys(state.resultsForQueries).includes(arr.id)) {
//console.log("Storing result:", arr);
var _n = _.cloneDeep(state);
_n.resultsForQueries[arr.id] = arr.result;
return _n;
}
//console.log("Discarding result:", arr);
return state;
case QueryWindowStateActions.FiredNewQueries:
var newState: QueryWindowState = _.cloneDeep(state);
let _action = action.value as FireNewQueriesData;
// Invalidate results
newState.resultsForQueries = {};
// Add a null result for each of the new IDs.
// Results will be added in as they come.
_action.resultIds && _action.resultIds.forEach((r: string) => {
newState.resultsForQueries[r] = null;
})
newState.query = _action.query;
newState.includeTypes = _action.includeTypes;
return newState;
case QueryWindowStateActions.SetEditingQuery:
return { ...state, editingQuery: action.value }
default:
throw new Error("Unimplemented QueryWindow state update.")
}
}
export default function QueryWindow(props: {}) {
const [state, dispatch] = useReducer(QueryWindowReducer, {
editingQuery: false,
query: null,
resultsForQueries: {},
includeTypes: [QueryItemType.Tracks, QueryItemType.Artists, QueryItemType.Albums, QueryItemType.Tags],
});
return <QueryWindowControlled state={state} dispatch={dispatch} />
}
export function QueryWindowControlled(props: {
state: QueryWindowState,
dispatch: (action: any) => void,
}) {
let { query, editingQuery, resultsForQueries, includeTypes } = props.state;
let { dispatch } = props;
// Call this function to fire new queries and prepare to receive their results.
// This will also set the query into the window state.
const doQueries = async (_query: QueryElem | null, itemTypes: QueryItemType[]) => {
var promises: Promise<any>[] = [];
var ids: string[] = itemTypes.map((i: any) => genUuid());
var query_fns = {
[QueryItemType.Albums]: queryAlbums,
[QueryItemType.Artists]: queryArtists,
[QueryItemType.Tracks]: queryTracks,
[QueryItemType.Tags]: queryTags,
};
let stateUpdateData: FireNewQueriesData = {
query: _query,
includeTypes: itemTypes,
resultIds: ids
};
// First dispatch to the state that we are firing new queries.
// This will update the query on the window page and invalidate
// any previous results on-screen.
dispatch({
type: QueryWindowStateActions.FiredNewQueries,
value: stateUpdateData
})
if (_query) {
console.log("Dispatching queries for:", _query);
itemTypes.forEach((itemType: QueryItemType, idx: number) => {
(promises as any[]).push(
(async () => {
let results = (await query_fns[itemType](
_query,
0, // TODO: pagination
100,
QueryResponseType.Details
)) as (
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]);
let r: ReceivedResultData = {
id: ids[idx],
result: {
kind: itemType,
results: results
}
};
dispatch({ type: QueryWindowStateActions.ReceivedResult, value: r })
})()
);
})
}
await Promise.all(promises);
};
let setEditingQuery = (e: boolean) => {
props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e });
}
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
width="80%"
>
<QueryBuilder
query={query}
onChangeQuery={(q: QueryElem | null) => {
doQueries(q, includeTypes)
}}
editing={editingQuery}
onChangeEditing={setEditingQuery}
requestFunctions={{
getArtists: getArtistNames,
getTrackNames: getTrackNames,
getAlbums: getAlbumNames,
getTags: getTagItems,
}}
/>
</Box>
<Box
m={1}
width="80%"
>
{(() => {
var rr = Object.values(resultsForQueries);
rr = rr.sort((r: ResultsForQuery | null) => {
if (r === null) { return 99; }
return {
[QueryItemType.Tracks]: 0,
[QueryItemType.Albums]: 1,
[QueryItemType.Artists]: 2,
[QueryItemType.Tags]: 3
}[r.kind];
});
// TODO: the sorting is not working
return rr.map((r: ResultsForQuery | null) => <>
{r !== null && r.kind == QueryItemType.Tracks && <>
<Typography variant="h5">Tracks</Typography>
<TracksTable tracks={r.results as QueryResponseTrackDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Albums && <>
<Typography variant="h5">Albums</Typography>
<AlbumsTable albums={r.results as QueryResponseAlbumDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Artists && <>
<Typography variant="h5">Artists</Typography>
<ArtistsTable artists={r.results as QueryResponseArtistDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Tags && <>
<Typography variant="h5">Tags</Typography>
<Typography>Found {r.results.length} tags.</Typography>
</>}
{r === null && <LinearProgress />}
</>);
})()}
</Box>
</Box>
}

@ -1,137 +0,0 @@
import React, { useReducer } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core";
import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
export enum RegistrationStatus {
NoneSubmitted = 0,
Successful,
Unsuccessful,
}
export interface RegisterWindowState extends WindowState {
email: string,
password: string,
status: RegistrationStatus,
}
export enum RegisterWindowStateActions {
SetEmail = "SetEmail",
SetPassword = "SetPassword",
SetStatus = "SetStatus",
}
export function RegisterWindowReducer(state: RegisterWindowState, action: any) {
switch (action.type) {
case RegisterWindowStateActions.SetEmail:
return { ...state, email: action.value }
case RegisterWindowStateActions.SetPassword:
return { ...state, password: action.value }
case RegisterWindowStateActions.SetStatus:
return { ...state, status: action.value }
default:
throw new Error("Unimplemented RegisterWindow state update.")
}
}
export default function RegisterWindow(props: {}) {
const [state, dispatch] = useReducer(RegisterWindowReducer, {
email: "",
password: "",
status: RegistrationStatus.NoneSubmitted,
});
return <RegisterWindowControlled state={state} dispatch={dispatch} />
}
export function RegisterWindowControlled(props: {
state: RegisterWindowState,
dispatch: (action: any) => void,
}) {
let history: any = useHistory();
let auth: Auth = useAuth();
const onSubmit = (event: any) => {
event.preventDefault();
auth.signup(props.state.email, props.state.password)
.then(() => {
props.dispatch({
type: RegisterWindowStateActions.SetStatus,
value: RegistrationStatus.Successful,
})
}).catch((e: any) => {
props.dispatch({
type: RegisterWindowStateActions.SetStatus,
value: RegistrationStatus.Unsuccessful,
})
})
}
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="500px"
>
<Paper>
<Box p={3}>
<Typography variant="h5">Sign up</Typography>
<form noValidate onSubmit={onSubmit}>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="email"
label="Email"
name="email"
autoFocus
onInput={(e: any) => props.dispatch({
type: RegisterWindowStateActions.SetEmail,
value: e.target.value
})}
/>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="password"
label="Password"
name="password"
type="password"
onInput={(e: any) => props.dispatch({
type: RegisterWindowStateActions.SetPassword,
value: e.target.value
})}
/>
{props.state.status === RegistrationStatus.Successful && <Alert severity="success">
Registration successful! Please {<Link to="/login">sign in</Link>} to continue.
</Alert>
}
{props.state.status === RegistrationStatus.Unsuccessful && <Alert severity="error">
Registration failed - please check your inputs and try again.
</Alert>
}
{props.state.status !== RegistrationStatus.Successful && <Button
type="submit"
fullWidth
variant="outlined"
color="primary"
>Sign up</Button>}
<Box display="flex" alignItems="center" mt={2}>
<Typography>Already have an account?</Typography>
<Box flexGrow={1} ml={2}><Button
onClick={() => history.replace("/login")}
fullWidth
variant="outlined"
color="primary"
>Sign in</Button></Box>
</Box>
</form>
</Box>
</Paper>
</Box>
</Box>
}

@ -1,359 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Box, CircularProgress, IconButton, Typography, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions, Dialog, DialogTitle } from '@material-ui/core';
import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations';
import AddIcon from '@material-ui/icons/Add';
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/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';
import Integration from '../../../lib/integration/Integration';
let _ = require('lodash')
// This widget is used to either display or edit a few
// specifically needed for Spotify Client credentials integration.
function EditSpotifyClientCredentialsDetails(props: {
clientId: string,
clientSecret: string | null,
editing: boolean,
onChangeClientId: (v: string) => void,
onChangeClientSecret: (v: string) => void,
}) {
return <Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
disabled={!props.editing}
value={props.clientId || ""}
label="Client id"
fullWidth
onChange={(e: any) => props.onChangeClientId(e.target.value)}
/>
</Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
disabled={!props.editing}
value={props.clientSecret === null ? "••••••••••••••••" : props.clientSecret}
label="Client secret"
fullWidth
onChange={(e: any) => {
props.onChangeClientSecret(e.target.value)
}}
onFocus={(e: any) => {
if (props.clientSecret === null) {
// Change from dots to empty input
console.log("Focus!")
props.onChangeClientSecret('');
}
}}
/>
</Box>
</Box>;
}
// An editing widget which is meant to either display or edit properties
// of an integration.
function EditIntegration(props: {
upstreamId?: number,
integration: serverApi.PostIntegrationRequest,
editing?: boolean,
showSubmitButton?: boolean | "InProgress",
showDeleteButton?: boolean | "InProgress",
showEditButton?: boolean,
showTestButton?: boolean | "InProgress",
showCancelButton?: boolean,
flashMessage?: React.ReactFragment,
isNew: boolean,
onChange?: (p: serverApi.PostIntegrationRequest) => void,
onSubmit?: (p: serverApi.PostIntegrationRequest) => void,
onDelete?: () => void,
onEdit?: () => void,
onTest?: () => void,
onCancel?: () => void,
}) {
let IntegrationHeaders: Record<any, any> = {
[serverApi.IntegrationImpl.SpotifyClientCredentials]:
<Box display="flex" alignItems="center">
<Box mr={1}>
{new IntegrationClasses[serverApi.IntegrationImpl.SpotifyClientCredentials](-1).getIcon({
style: { height: '40px', width: '40px' }
})}
</Box>
<Typography>Spotify (using Client Credentials)</Typography>
</Box>,
[serverApi.IntegrationImpl.YoutubeWebScraper]:
<Box display="flex" alignItems="center">
<Box mr={1}>
{new IntegrationClasses[serverApi.IntegrationImpl.YoutubeWebScraper](-1).getIcon({
style: { height: '40px', width: '40px' }
})}
</Box>
<Typography>Youtube Music (using experimental web scraper)</Typography>
</Box>,
}
let IntegrationDescription: Record<any, any> = {
[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
metadata.<br />
Please see the Spotify API documentation on how to generate a client ID
and client secret. Once set, you will only be able to overwrite the secret
here, not read it.
</Typography>,
[serverApi.IntegrationImpl.YoutubeWebScraper]:
<Typography>
This integration allows using the public Youtube Music search page to scrape
for music metadata. <br />
Because it relies on reverse-engineering of a web page that may change in the
future, this is considered to be experimental and unstable. However, the music links acquired
using this method are expected to remain reasonably stable.
</Typography>,
}
return <Card variant="outlined">
<CardHeader
avatar={
IntegrationHeaders[props.integration.type]
}
>
</CardHeader>
<CardContent>
<Box mb={2}>{IntegrationDescription[props.integration.type]}</Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
value={props.integration.name || ""}
label="Integration name"
fullWidth
disabled={!props.editing}
onChange={(e: any) => props.onChange && props.onChange({
...props.integration,
name: e.target.value,
})}
/>
</Box>
{props.integration.type === serverApi.IntegrationImpl.SpotifyClientCredentials &&
<EditSpotifyClientCredentialsDetails
clientId={'clientId' in props.integration.details &&
props.integration.details.clientId || ""}
clientSecret={props.integration.secretDetails && 'clientSecret' in props.integration.secretDetails ?
props.integration.secretDetails.clientSecret :
(props.isNew ? "" : null)}
editing={props.editing || false}
onChangeClientId={(v: string) => props.onChange && props.onChange({
...props.integration,
details: {
...props.integration.details,
clientId: v,
}
})}
onChangeClientSecret={(v: string) => props.onChange && props.onChange({
...props.integration,
secretDetails: {
...props.integration.secretDetails,
clientSecret: v,
}
})}
/>
}
{props.flashMessage && props.flashMessage}
</CardContent>
<CardActions>
{props.showEditButton && <IconButton
onClick={props.onEdit}
><EditIcon /></IconButton>}
{props.showSubmitButton && <IconButton
onClick={() => props.onSubmit && props.onSubmit(props.integration)}
><CheckIcon /></IconButton>}
{props.showDeleteButton && <IconButton
onClick={props.onDelete}
><DeleteIcon /></IconButton>}
{props.showCancelButton && <IconButton
onClick={props.onCancel}
><ClearIcon /></IconButton>}
{props.showTestButton && <Button
onClick={props.onTest}
>Test</Button>}
</CardActions>
</Card>
}
let EditorWithTest = (props: any) => {
const [testFlashMessage, setTestFlashMessage] =
React.useState<React.ReactFragment | undefined>(undefined);
let { integration, ...rest } = props;
return <EditIntegration
onTest={() => {
integration.integration.test({})
.then(() => {
setTestFlashMessage(
<Alert severity="success">Integration is active.</Alert>
)
})
.catch((e: any) => {
setTestFlashMessage(
<Alert severity="error">Failed test: {e.message}</Alert>
)
})
}}
flashMessage={testFlashMessage}
showTestButton={true}
integration={integration.properties}
{...rest}
/>;
}
function AddIntegrationMenu(props: {
position: null | number[],
open: boolean,
onClose?: () => void,
onAdd?: (type: serverApi.IntegrationImpl) => void,
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<MenuItem
onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationImpl.SpotifyClientCredentials);
props.onClose && props.onClose();
}}
>Spotify via Client Credentials</MenuItem>
<MenuItem
onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationImpl.YoutubeWebScraper);
props.onClose && props.onClose();
}}
>Youtube Music Web Scraper</MenuItem>
</Menu>
}
function EditIntegrationDialog(props: {
open: boolean,
onClose?: () => void,
upstreamId?: number,
integration: IntegrationState,
onSubmit?: (p: serverApi.PostIntegrationRequest) => void,
isNew: boolean,
}) {
let [editingIntegration, setEditingIntegration] =
useState<IntegrationState>(props.integration);
useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]);
return <Dialog
onClose={props.onClose}
open={props.open}
disableBackdropClick={true}
>
<DialogTitle>Edit Integration</DialogTitle>
<EditIntegration
isNew={props.isNew}
editing={true}
upstreamId={props.upstreamId}
integration={editingIntegration.properties}
showCancelButton={true}
showSubmitButton={props.onSubmit !== undefined}
showTestButton={false}
onCancel={props.onClose}
onSubmit={props.onSubmit}
onChange={(i: any) => {
setEditingIntegration({
...editingIntegration,
properties: i,
integration: makeIntegration(i, editingIntegration.id),
});
}}
/>
</Dialog>
}
export default function IntegrationSettings(props: {}) {
const [addMenuPos, setAddMenuPos] = React.useState<null | number[]>(null);
const [editingState, setEditingState] = React.useState<IntegrationState | null>(null);
let {
state: integrations,
addIntegration,
modifyIntegration,
deleteIntegration,
updateFromUpstream,
} = useIntegrations();
const onOpenAddMenu = (e: any) => {
setAddMenuPos([e.clientX, e.clientY])
};
const onCloseAddMenu = () => {
setAddMenuPos(null);
};
return <>
<Box>
{integrations === null && <CircularProgress />}
{Array.isArray(integrations) && <Box display="flex" flexDirection="column" alignItems="center" flexWrap="wrap">
{integrations.map((state: IntegrationState) => <Box m={1} width="90%">
<EditorWithTest
upstreamId={state.id}
integration={state}
showEditButton={true}
showDeleteButton={true}
onEdit={() => { setEditingState(state); }}
onDelete={() => {
deleteIntegration(state.id)
.then(updateFromUpstream)
}}
/>
</Box>)}
<IconButton onClick={onOpenAddMenu}>
<AddIcon />
</IconButton>
</Box>}
</Box>
<AddIntegrationMenu
position={addMenuPos}
open={addMenuPos !== null}
onClose={onCloseAddMenu}
onAdd={(type: serverApi.IntegrationImpl) => {
let p = makeDefaultIntegrationProperties(type);
setEditingState({
properties: p,
integration: makeIntegration(p, -1),
id: -1,
})
}}
/>
{editingState && <EditIntegrationDialog
open={!(editingState === null)}
onClose={() => { setEditingState(null); }}
integration={editingState}
isNew={editingState.id === -1}
onSubmit={(v: serverApi.PostIntegrationRequest) => {
if (editingState.id >= 0) {
const id = editingState.id;
setEditingState(null);
modifyIntegration(id, v)
.then(updateFromUpstream)
} else {
setEditingState(null);
createIntegration({
...v,
secretDetails: v.secretDetails || {},
})
.then(updateFromUpstream)
}
}}
/>}
</>;
}

@ -1,59 +0,0 @@
import React, { useReducer } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core";
import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
import IntegrationSettingsEditor from './IntegrationSettings';
export enum SettingsTab {
Integrations = 0,
}
export interface SettingsWindowState extends WindowState {
activeTab: SettingsTab,
}
export enum SettingsWindowStateActions {
SetActiveTab = "SetActiveTab",
}
export function SettingsWindowReducer(state: SettingsWindowState, action: any) {
switch (action.type) {
case SettingsWindowStateActions.SetActiveTab:
return { ...state, activeTab: action.value }
default:
throw new Error("Unimplemented SettingsWindow state update.")
}
}
export default function SettingsWindow(props: {}) {
const [state, dispatch] = useReducer(SettingsWindowReducer, {
activeTab: SettingsTab.Integrations,
});
return <SettingsWindowControlled state={state} dispatch={dispatch} />
}
export function SettingsWindowControlled(props: {
state: SettingsWindowState,
dispatch: (action: any) => void,
}) {
let history: any = useHistory();
let auth: Auth = useAuth();
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="60%"
>
<Paper>
<Box p={3}>
<Box mb={3}><Typography variant="h5">User Settings</Typography></Box>
<Typography variant="h6">Integrations</Typography>
<IntegrationSettingsEditor/>
</Box>
</Paper>
</Box>
</Box>
}

@ -1,204 +0,0 @@
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/api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ItemsTable, ColumnType, TracksTable } from '../../tables/ResultsTable';
import { modifyTag } from '../../../lib/backend/tags';
import { queryTags, queryTracks } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { useParams } from 'react-router';
import { Id, Track, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export interface FullTagMetadata extends serverApi.QueryResponseTagDetails {
fullName: string[],
fullId: number[],
}
export type TagMetadata = FullTagMetadata;
export type TagMetadataChanges = serverApi.PatchTagRequest;
export interface TagWindowState extends WindowState {
id: number,
metadata: TagMetadata | null,
pendingChanges: TagMetadataChanges | null,
tracksWithTag: any[] | null,
}
export enum TagWindowStateActions {
SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetTracks = "SetTracks",
Reload = "Reload",
}
export function TagWindowReducer(state: TagWindowState, action: any) {
switch (action.type) {
case TagWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case TagWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case TagWindowStateActions.SetTracks:
return { ...state, tracksWithTag: action.value }
case TagWindowStateActions.Reload:
return { ...state, metadata: null, tracksWithTag: null }
default:
throw new Error("Unimplemented TagWindow state update.")
}
}
export async function getTagMetadata(id: number): Promise<FullTagMetadata> {
let tags: any = await queryTags(
{
a: QueryLeafBy.TagId,
b: id,
leafOp: QueryLeafOp.Equals,
}, 0, 1, serverApi.QueryResponseType.Details
);
var tag = tags[0];
// Recursively fetch parent tags to build the full metadata.
if (tag.parentId) {
const parent = await getTagMetadata(tag.parentId);
tag.fullName = [...parent.fullName, tag.name];
tag.fullId = [...parent.fullId, tag.tagId];
} else {
tag.fullName = [tag.name];
tag.fullId = [tag.tagId];
}
return tag;
}
export default function TagWindow(props: {}) {
const { id } = useParams<{ id: string }>();
const [state, dispatch] = useReducer(TagWindowReducer, {
id: parseInt(id),
metadata: null,
pendingChanges: null,
tracksWithTag: null,
});
return <TagWindowControlled state={state} dispatch={dispatch} />
}
export function TagWindowControlled(props: {
state: TagWindowState,
dispatch: (action: any) => void,
}) {
let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
let { id: tagId, tracksWithTag } = props.state;
let dispatch = props.dispatch;
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the tag's metadata.
useEffect(() => {
if (metadata === null) {
getTagMetadata(tagId)
.then((m: TagMetadata) => {
dispatch({
type: TagWindowStateActions.SetMetadata,
value: m
});
})
}
}, [tagId, dispatch, metadata]);
// Effect to get the tag's tracks.
useEffect(() => {
if (tracksWithTag) { return; }
(async () => {
const tracks: any = await queryTracks(
{
a: QueryLeafBy.TagId,
b: tagId,
leafOp: QueryLeafOp.Equals,
}, 0, -1, serverApi.QueryResponseType.Details,
);
dispatch({
type: TagWindowStateActions.SetTracks,
value: tracks,
});
})();
}, [tracksWithTag, tagId, dispatch]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown tag name)"}</Typography>
const fullName = <Box display="flex" alignItems="center">
{metadata?.fullName.map((n: string, i: number) => {
if (metadata?.fullName && i === metadata?.fullName.length - 1) {
return name;
} else if (i >= (metadata?.fullName.length || 0) - 1) {
return undefined;
} else {
return <Typography variant="h4">{n}&nbsp;/&nbsp;</Typography>
}
})}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<LocalOfferIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{fullName}
</Box>
</Box>}
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks with this tag in your library:</Typography>
</Box>
{props.state.tracksWithTag && <TracksTable tracks={props.state.tracksWithTag}/>}
{!props.state.tracksWithTag && <CircularProgress />}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTagRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified: serverApi.PatchTagRequest = {
mbApi_typename: 'tag',
name: v.name,
parent: undefined,
parentId: v.parentId || v.parent?.id || undefined,
};
modifyTag(tagId, v_modified)
.then(() => dispatch({
type: TagWindowStateActions.Reload
}))
}}
id={tagId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={false}
/>}
</Box>
}

@ -1,175 +0,0 @@
import React, { useEffect, useState, useReducer } from 'react';
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/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 { queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import EditIcon from '@material-ui/icons/Edit';
import { modifyTrack } from '../../../lib/saveChanges';
import { getTrack } from '../../../lib/backend/tracks';
import { Artist, Id, ResourceType, Tag } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
export type TrackMetadata = serverApi.QueryResponseTrackDetails;
export interface TrackWindowState extends WindowState {
id: number,
metadata: TrackMetadata | null,
}
export enum TrackWindowStateActions {
SetMetadata = "SetMetadata",
Reload = "Reload",
}
export function TrackWindowReducer(state: TrackWindowState, action: any) {
switch (action.type) {
case TrackWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
case TrackWindowStateActions.Reload:
return { ...state, metadata: null }
default:
throw new Error("Unimplemented TrackWindow state update.")
}
}
export default function TrackWindow(props: {}) {
const { id } = useParams<{ id: string }>();
const [state, dispatch] = useReducer(TrackWindowReducer, {
id: parseInt(id),
metadata: null,
});
return <TrackWindowControlled state={state} dispatch={dispatch} />
}
export function TrackWindowControlled(props: {
state: TrackWindowState,
dispatch: (action: any) => void,
}) {
let { metadata, id: trackId } = props.state;
let { dispatch } = props;
let [editing, setEditing] = useState<boolean>(false);
useEffect(() => {
if (metadata === null) {
getTrack(trackId)
.then((m: serverApi.GetTrackResponse) => {
dispatch({
type: TrackWindowStateActions.SetMetadata,
value: m
});
})
}
}, [trackId, dispatch, metadata]);
const title = <Typography variant="h4">{metadata?.name || "(Unknown track title)"}</Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: (serverApi.Artist & serverApi.Name)) => {
return <Typography>
{artist.name}
</Typography>
});
const album = metadata?.album && <Typography>
{metadata?.album.name}
</Typography>;
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>
});
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<AudiotrackIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Box>
<Box m={2}>
{title}
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<PersonIcon />
<Box m={0.5}>
{artists}
</Box>
</Box>
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<AlbumIcon />
<Box m={0.5}>
{album}
</Box>
</Box>
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTrackRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
album: undefined,
artists: undefined,
tags: undefined,
albumId: v.albumId || v.album?.id || undefined,
artistIds: v.artistIds || v.artists?.map (
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map (
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyTrack(trackId, v_modified)
.then(() => dispatch({
type: TrackWindowStateActions.Reload
}))
}}
id={trackId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Title', type: EditablePropertyType.Text },
]}
resourceType={ResourceType.Track}
editStoreLinks={true}
defaultExternalLinksQuery={`${metadata.name}${metadata.artists && ` ${metadata.artists[0].name}`}${metadata.album && ` ${metadata.album.name}`}`}
/>}
</Box>
}

@ -1,14 +0,0 @@
import * as serverApi from '../../api/api';
import { GetAlbumResponse } from '../../api/api';
import backendRequest from './request';
export async function getAlbum(id: number): Promise<GetAlbumResponse> {
if (isNaN(id)) {
throw new Error("Cannot request a NaN album.");
}
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));
}
return await response.json();
}

@ -1,14 +0,0 @@
import * as serverApi from '../../api/api';
import { GetArtistResponse } from '../../api/api';
import backendRequest from './request';
export async function getArtist(id: number): Promise<GetArtistResponse> {
if (isNaN(id)) {
throw new Error("Cannot request a NaN artist.");
}
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));
}
return await response.json();
}

@ -1,32 +0,0 @@
import { DBExportEndpoint, DBImportEndpoint, DBImportRequest, DBWipeEndpoint } from "../../api/api";
import backendRequest from "./request";
export function getDBExportLink() {
return (process.env.REACT_APP_BACKEND || "") + DBExportEndpoint;
}
export async function wipeDB() {
const requestOpts = {
method: 'POST'
};
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + DBWipeEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to DB wipe not OK: " + JSON.stringify(response));
}
return;
}
export async function importDB(jsonData: DBImportRequest) {
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jsonData),
};
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + DBImportEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to DB import not OK: " + JSON.stringify(response));
}
}

@ -1,68 +0,0 @@
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.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.PostIntegrationEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to integration creation not OK: " + JSON.stringify(response));
}
return await response.json();
}
export async function modifyIntegration(id: number, details: serverApi.PatchIntegrationRequest): Promise<serverApi.PatchIntegrationResponse> {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.PatchIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration Patch not OK: " + JSON.stringify(response));
}
}
export async function deleteIntegration(id: number): Promise<serverApi.DeleteIntegrationResponse> {
const requestOpts = {
method: 'DELETE',
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration deletion not OK: " + JSON.stringify(response));
}
}
export async function getIntegrations(): Promise<serverApi.ListIntegrationsResponse> {
const requestOpts = {
method: 'GET',
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint,
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration list not OK: " + JSON.stringify(response));
}
let json = await response.json();
return json;
}

@ -1,112 +0,0 @@
import * as serverApi from '../../api/api';
import { QueryElem, QueryFor, simplify, toApiQuery } from '../query/Query';
import backendRequest from './request';
export async function queryItems(
type: serverApi.ResourceType,
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponse> {
const queryForMapping : any = {
[serverApi.ResourceType.Album]: QueryFor.Albums,
[serverApi.ResourceType.Artist]: QueryFor.Artists,
[serverApi.ResourceType.Tag]: QueryFor.Tags,
[serverApi.ResourceType.Track]: QueryFor.Tracks,
};
const simplified = simplify(query || null, queryForMapping[type]);
if (simplified === null && query != undefined) {
// Invalid query, return no results.
if (responseType === serverApi.QueryResponseType.Count) {
return (async () => { return {
tracks: 0,
artists: 0,
tags: 0,
albums: 0,
}; })();
} else {
return (async () => { return {
tracks: [],
artists: [],
tags: [],
albums: [],
}; })();
}
}
var q: serverApi.QueryRequest = {
query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {},
offsetsLimits: {
artistOffset: (type == serverApi.ResourceType.Artist) ? (offset || 0) : undefined,
artistLimit: (type == serverApi.ResourceType.Artist) ? (limit || -1) : undefined,
albumOffset: (type == serverApi.ResourceType.Album) ? (offset || 0) : undefined,
albumLimit: (type == serverApi.ResourceType.Album) ? (limit || -1) : undefined,
trackOffset: (type == serverApi.ResourceType.Track) ? (offset || 0) : undefined,
trackLimit: (type == serverApi.ResourceType.Track) ? (limit || -1) : undefined,
tagOffset: (type == serverApi.ResourceType.Tag) ? (offset || 0) : undefined,
tagLimit: (type == serverApi.ResourceType.Tag) ? (limit || -1) : undefined,
},
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;
})();
}
export async function queryArtists(
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseArtistDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Artist, query, offset, limit, responseType);
return r.artists;
}
export async function queryAlbums(
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseAlbumDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Album, query, offset, limit, responseType);
return r.albums;
}
export async function queryTracks(
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTrackDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Track, query, offset, limit, responseType);
return r.tracks;
}
export async function queryTags(
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTagDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Tag, query, offset, limit, responseType);
return r.tags;
}

@ -1,34 +0,0 @@
import { ResponsiveFontSizesOptions } from "@material-ui/core/styles/responsiveFontSizes";
import { useHistory } from "react-router";
import { Auth } from "../useAuth";
export class NotLoggedInError extends Error {
constructor(message: string) {
super(message);
this.name = "NotLoggedInError";
}
}
export function isNotLoggedInError(e: any): e is NotLoggedInError {
return e.name === "NotLoggedInError";
}
export default async function backendRequest(url: any, ...restArgs: any[]): Promise<Response> {
let response = await fetch(url, ...restArgs);
if (response.status === 401 && (await response.json()).reason === "NotLoggedIn") {
console.log("Not logged in!")
throw new NotLoggedInError("Not logged in.");
}
return response;
}
export function handleNotLoggedIn(auth: Auth, e: any) {
console.log("Error:", e);
if (isNotLoggedInError(e)) {
console.log("Not logged in!")
auth.signout();
return;
}
// Rethrow if unhandled
throw e;
}

@ -1,62 +0,0 @@
import * as serverApi from '../../api/api';
import backendRequest from './request';
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.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.PatchTagRequest) {
const requestOpts = {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.PatchTagEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to tag modification not OK: " + JSON.stringify(response));
}
}
export async function deleteTag(id: number) {
const requestOpts = {
method: 'DELETE',
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to tag deletion not OK: " + JSON.stringify(response));
}
}
export async function mergeTag(fromId: number, toId: number) {
const requestOpts = {
method: 'POST',
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint
.replace(':id', fromId.toString())
.replace(':toId', toId.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to tag merge not OK: " + JSON.stringify(response));
}
}

@ -1,13 +0,0 @@
import * as serverApi from '../../api/api';
import backendRequest from './request';
export async function getTrack(id: number): Promise<serverApi.GetTrackResponse> {
if (isNaN(id)) {
throw new Error("Cannot request a NaN track.");
}
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,61 +0,0 @@
import React, { ReactFragment } from 'react';
import { IntegrationWith } from '../../api/api';
export interface IntegrationAlbum {
name?: string,
artist?: IntegrationArtist,
url?: string, // An URL to access the item externally.
}
export interface IntegrationArtist {
name?: string,
url?: string, // An URL to access the item externally.
}
export interface IntegrationTrack {
title?: string,
album?: IntegrationAlbum,
artist?: IntegrationArtist,
url?: string, // An URL to access the item externally.
}
export enum IntegrationFeature {
// Used to test whether the integration is active.
Test = 0,
// Used to get a bucket of songs (typically: the whole library)
GetTracks,
// Used to search items and get some amount of candidate results.
SearchTrack,
SearchAlbum,
SearchArtist,
}
export interface IntegrationDescriptor {
supports: IntegrationFeature[],
}
export default class Integration {
constructor(integrationId: number) { }
// Common
getFeatures(): IntegrationFeature[] { return []; }
getIcon(props: any): ReactFragment { return <></> }
providesStoreLink(): IntegrationWith | null { return null; }
// Requires feature: Test
async test(testParams: any): Promise<void> {}
// Requires feature: GetTracks
async getTracks(getTracksParams: any): Promise<IntegrationTrack[]> { 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 []; }
// Requires feature: SearchArtist
async searchArtist(query: string, limit: number): Promise<IntegrationArtist[]> { return []; }
}

@ -1,112 +0,0 @@
import React from 'react';
import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationTrack } from '../Integration';
import StoreLinkIcon from '../../../components/common/StoreLinkIcon';
import { IntegrationWith } from '../../../api/api';
enum SearchType {
Track = 'track',
Artist = 'artist',
Album = 'album',
};
export default class SpotifyClientCreds extends Integration {
integrationId: number;
constructor(integrationId: number) {
super(integrationId);
this.integrationId = integrationId;
}
getFeatures(): IntegrationFeature[] {
return [
IntegrationFeature.Test,
IntegrationFeature.SearchTrack,
IntegrationFeature.SearchAlbum,
IntegrationFeature.SearchArtist,
]
}
getIcon(props: any) {
return <StoreLinkIcon whichStore={IntegrationWith.Spotify} {...props} />
}
providesStoreLink() {
return IntegrationWith.Spotify;
}
async test(testParams: {}) {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
if (!response.ok) {
throw new Error("Spotify Client Credentials test failed: " + JSON.stringify(response));
}
console.log("Spotify test response:", await response.json())
}
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);
}
async searchArtist(query: string, limit: number): Promise<IntegrationArtist[]> {
return this.search(query, SearchType.Artist, limit);
}
async search(query: string, type: SearchType, limit: number):
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}`);
if (!response.ok) {
throw new Error("Spotify Client Credentials search failed: " + JSON.stringify(response));
}
let json = await response.json();
console.log("Response:", json);
switch(type) {
case SearchType.Track: {
return json.tracks.items.map((r: any): IntegrationTrack => {
return {
title: r.name,
url: r.external_urls.spotify,
artist: {
name: r.artists && r.artists[0].name,
url: r.artists && r.artists[0].external_urls.spotify,
},
album: {
name: r.album && r.album.name,
url: r.album && r.album.external_urls.spotify,
}
}
})
}
case SearchType.Artist: {
return json.artists.items.map((r: any): IntegrationArtist => {
return {
name: r.name,
url: r.external_urls.spotify,
}
})
}
case SearchType.Album: {
return json.albums.items.map((r: any): IntegrationAlbum => {
return {
name: r.name,
url: r.external_urls.spotify,
artist: {
name: r.artists[0].name,
url: r.artists[0].external_urls.spotify,
},
}
})
}
}
}
}

@ -1,178 +0,0 @@
import React, { useState, useContext, createContext, useReducer, useEffect } from "react";
import Integration from "./Integration";
import * as serverApi from '../../api/api';
import SpotifyClientCreds from "./spotify/SpotifyClientCreds";
import * as backend from "../backend/integrations";
import { handleNotLoggedIn, NotLoggedInError } from "../backend/request";
import { useAuth } from "../useAuth";
import YoutubeMusicWebScraper from "./youtubemusic/YoutubeMusicWebScraper";
export type IntegrationState = {
id: number,
integration: Integration,
properties: serverApi.PostIntegrationRequest,
};
export type IntegrationsState = IntegrationState[] | "Loading";
export function isIntegrationState(v: any): v is IntegrationState {
return 'id' in v && 'integration' in v && 'properties' in v;
}
export interface Integrations {
state: IntegrationsState,
addIntegration: (v: serverApi.PostIntegrationRequest) => Promise<number>,
deleteIntegration: (id: number) => Promise<void>,
modifyIntegration: (id: number, v: serverApi.PostIntegrationRequest) => Promise<void>,
updateFromUpstream: () => Promise<void>,
};
export const IntegrationClasses: Record<any, any> = {
[serverApi.IntegrationImpl.SpotifyClientCredentials]: SpotifyClientCreds,
[serverApi.IntegrationImpl.YoutubeWebScraper]: YoutubeMusicWebScraper,
}
export function makeDefaultIntegrationProperties(type: serverApi.IntegrationImpl):
serverApi.PostIntegrationRequest {
switch (type) {
case serverApi.IntegrationImpl.SpotifyClientCredentials: {
return {
mbApi_typename: 'integrationData',
name: "Spotify App",
type: type,
details: { clientId: "" },
secretDetails: { clientSecret: "" },
}
}
case serverApi.IntegrationImpl.YoutubeWebScraper: {
return {
mbApi_typename: 'integrationData',
name: "Youtube Music Web Scraper",
type: type,
details: {},
secretDetails: {},
}
}
default: {
throw new Error("Unimplemented default integration.")
}
}
}
export function makeIntegration(p: serverApi.PostIntegrationRequest, id: number) {
switch (p.type) {
case serverApi.IntegrationImpl.SpotifyClientCredentials: {
return new SpotifyClientCreds(id);
}
case serverApi.IntegrationImpl.YoutubeWebScraper: {
return new YoutubeMusicWebScraper(id);
}
default: {
throw new Error("Unimplemented integration type.")
}
}
}
const integrationsContext = createContext<Integrations>({
state: [],
addIntegration: async () => 0,
modifyIntegration: async () => { },
deleteIntegration: async () => { },
updateFromUpstream: async () => { },
});
export function ProvideIntegrations(props: { children: any }) {
const integrations = useProvideIntegrations();
return <integrationsContext.Provider value={integrations}>{props.children}</integrationsContext.Provider>;
}
export const useIntegrations = () => {
return useContext(integrationsContext);
};
function useProvideIntegrations(): Integrations {
let auth = useAuth();
enum IntegrationsActions {
SetItem = "SetItem",
Set = "Set",
DeleteItem = "DeleteItem",
AddItem = "AddItem",
}
let IntegrationsReducer = (state: IntegrationsState, action: any): IntegrationsState => {
switch (action.type) {
case IntegrationsActions.SetItem: {
if (state !== "Loading") {
return state.map((item: any) => {
return (item.id === action.id) ? action.value : item;
})
}
return state;
}
case IntegrationsActions.Set: {
return action.value;
}
case IntegrationsActions.DeleteItem: {
if (state !== "Loading") {
const newState = [...state];
return newState.filter((item: any) => item.id !== action.id);
}
return state;
}
case IntegrationsActions.AddItem: {
return [...state, action.value];
}
default:
throw new Error("Unimplemented Integrations state update.")
}
}
const [state, dispatch] = useReducer(IntegrationsReducer, [])
let updateFromUpstream = async () => {
try {
return await backend.getIntegrations()
.then((integrations: serverApi.ListIntegrationsResponse) => {
dispatch({
type: IntegrationsActions.Set,
value: integrations.map((i: any) => {
return {
integration: new (IntegrationClasses[i.type])(i.id),
properties: { ...i },
id: i.id,
}
})
});
})
.catch((e) => handleNotLoggedIn(auth, e));
} catch(e) {}
}
let addIntegration = async (v: serverApi.PostIntegrationRequest) => {
const id = await backend.createIntegration(v).catch((e: any) => { handleNotLoggedIn(auth, e) });
await updateFromUpstream();
return (id as serverApi.PostIntegrationResponse).id;
}
let deleteIntegration = async (id: number) => {
await backend.deleteIntegration(id).catch((e: any) => { handleNotLoggedIn(auth, e) });
await updateFromUpstream();
}
let modifyIntegration = async (id: number, v: serverApi.PostIntegrationRequest) => {
await backend.modifyIntegration(id, v).catch((e: any) => { handleNotLoggedIn(auth, e) });
await updateFromUpstream();
}
useEffect(() => {
if (auth.user) {
updateFromUpstream()
}
}, [auth]);
return {
state: state,
addIntegration: addIntegration,
modifyIntegration: modifyIntegration,
deleteIntegration: deleteIntegration,
updateFromUpstream: updateFromUpstream,
}
}

@ -1,310 +0,0 @@
import React from 'react';
import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationTrack } from '../Integration';
import StoreLinkIcon from '../../../components/common/StoreLinkIcon';
import { IntegrationWith } from '../../../api/api';
import { runInNewContext } from 'vm';
import { TextRotateVertical } from '@material-ui/icons';
import AlbumWindow from '../../../components/windows/album/AlbumWindow';
import { isUndefined } from 'util';
import { keys } from '@material-ui/core/styles/createBreakpoints';
import stringifyList from '../../stringifyList';
import { convertCompilerOptionsFromJson } from 'typescript';
let _ = require('lodash');
enum SearchType {
Track = 'track',
Artist = 'artist',
Album = 'album',
};
export function extractInitialData(text: string): any | undefined {
// At the time of writing this, the scraper is trying to capture from the following block:
//
// initialData.push({
// path: ...,
// params: {"query":"something"},
// data: "THIS",
// });
//
// the THIS part.
//
// Another variant was found in the field, where there was also additional encoding involved:
//
// initialData.push({
// path: '\/search',
// params: JSON.parse('\x7b\x22query\x22:\x22something\x22\x7d')
// data: 'THIS2'
// })
// , where THIS2 was a string which also contained escape characters like \x7b and \x22.
// Handle the 1st case.
let pattern = /initialData\.push\({[\n\r\s]*path:.*[\n\r\s]+params:\s*{\s*['"]query['"].*[\n\r\s]+data:\s*['"](.*)['"]\s*[\n\r]/
let m = text.match(pattern);
let dataline1 = Array.isArray(m) && m.length >= 2 ? m[1] : undefined;
// Now parse the data line.
let dataline1_clean = dataline1 ? dataline1.replace(/\\"/g, '"').replace(/\\\\"/g, '\\"') : undefined;
let json1 = dataline1_clean ? JSON.parse(dataline1_clean) : undefined;
// Handle the 2nd case.
let m2 = text.match(/params:[\s]*JSON\.parse\('([^']*)'\),[\n\r\s]*data:[\s]*'([^']*)'/g);
let json2: any = undefined;
if (Array.isArray(m2)) {
m2.forEach((match: string) => {
let decode = (s: string) => {
var r = /\\x([\d\w]{2})/gi;
let res = s.replace(r, function (match, grp) {
return String.fromCharCode(parseInt(grp, 16));
});
return unescape(res);
}
let paramsline: string = decode((match.match(/params:[\s]*JSON\.parse\('([^']*)'/) as string[])[1]);
if (!('query' in JSON.parse(paramsline))) {
return;
}
let dataline2: string = decode((match.match(/data:[\s]*'([^']*)'/) as string[])[1]);
json2 = JSON.parse(dataline2);
})
}
// Return either one that worked.
let result = json1 || json2;
//console.log("initial data:", result);
return result;
}
// Helper function to recursively find key-value pairs in an Object.
function findRecursive (obj : Object | any[],
match_fn : (keys: any[], keys_str: string, value: any) => boolean,
find_inside_matches: boolean,
prev_keys : any[] = []) : any[] {
var retval : any[] = [];
for (const [key, value] of Object.entries(obj)) {
var keys : any[] = prev_keys.concat([key]);
let keys_str : string = keys.map((k:any) => String(k)).join('.');
if (match_fn (keys, keys_str, value)) {
retval.push(value);
if (!find_inside_matches) {
continue;
}
}
if (typeof value === 'object' && value !== null) {
retval = retval.concat(findRecursive(value, match_fn, find_inside_matches, keys));
}
}
return retval;
}
export function parseItems(initialData: any): {
tracks: IntegrationTrack[],
albums: IntegrationAlbum[],
artists: IntegrationArtist[],
} {
try {
let retval: any = {
tracks: [],
albums: [],
artists: [],
};
let parseTrack: (...args: any) => IntegrationTrack | undefined = (renderer: any, runs: any[]) => {
let track: IntegrationTrack = {};
runs.forEach((run: any) => {
let maybeVideoId = _.get(run, 'navigationEndpoint.watchEndpoint.videoId');
if (maybeVideoId) {
track.url = `https://music.youtube.com/watch?v=${maybeVideoId}`;
track.title = _.get(run, 'text');
} else if (_.get(run,
'navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType')
=== 'MUSIC_PAGE_TYPE_ALBUM') {
track.album = {
url: `https://music.youtube.com/browse/${_.get(run, 'navigationEndpoint.browseEndpoint.browseId')}`,
name: _.get(run, 'text'),
}
} else if (_.get(run,
'navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType')
=== 'MUSIC_PAGE_TYPE_ARTIST') {
track.artist = {
url: `https://music.youtube.com/browse/${_.get(run, 'navigationEndpoint.browseEndpoint.browseId')}`,
name: _.get(run, 'text'),
}
}
})
if (track.artist && track.album) {
track.album.artist = track.artist;
}
if (track.title && track.album && track.url && track.artist) {
return track;
}
return undefined;
}
let parseAlbum: (...args: any) => IntegrationAlbum | undefined = (renderer: any, runs: any[]) => {
let album: IntegrationAlbum = {};
let maybeBrowseId = _.get(renderer, 'navigationEndpoint.browseEndpoint.browseId')
if (maybeBrowseId) {
album.url = `https://music.youtube.com/browse/${maybeBrowseId}`;
}
let maybeName = _.get(runs[0], 'text');
if (maybeName) {
album.name = maybeName;
}
runs.forEach((run: any) => {
if (_.get(run,
'navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType')
=== 'MUSIC_PAGE_TYPE_ARTIST') {
album.artist = {
url: `https://music.youtube.com/browse/${_.get(run, 'navigationEndpoint.browseEndpoint.browseId')}`,
name: _.get(run, 'text'),
}
}
})
return album;
}
let parseArtist: (...args: any) => IntegrationArtist | undefined = (renderer: any, runs: any[]) => {
let artist: IntegrationArtist = {};
let maybeBrowseId = _.get(renderer, 'navigationEndpoint.browseEndpoint.browseId')
if (maybeBrowseId) {
artist.url = `https://music.youtube.com/browse/${maybeBrowseId}`;
}
let maybeName = _.get(runs[0], 'text');
if (maybeName) {
artist.name = maybeName;
}
return artist;
}
// Gather all the items.
var musicResponsiveListItemRenderers =
findRecursive(initialData, (keys: any[], keys_str : string, val: any) => {
return keys_str.match(/.*musicResponsiveListItemRenderer$/g) !== null;
}, false);
musicResponsiveListItemRenderers.forEach((renderer: any) => {
let runs = _.get(renderer, 'flexColumns').map((column: any) => {
return _.get(column, 'musicResponsiveListItemFlexColumnRenderer.text.runs');
}).flat();
switch (_.get(renderer, 'flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text', '')) {
case "Song": {
retval.tracks.push(parseTrack(renderer, runs));
break;
}
case "Artist": {
retval.artists.push(parseArtist(renderer, runs));
break;
}
case "Album":
case "Single": {
retval.albums.push(parseAlbum(renderer, runs));
break;
}
default: {
break;
}
}
})
return retval;
} catch (e) {
console.log("Error parsing items:", e.message);
return { tracks: [], albums: [], artists: [] }
}
}
export default class YoutubeMusicWebScraper extends Integration {
integrationId: number;
constructor(integrationId: number) {
super(integrationId);
this.integrationId = integrationId;
}
getFeatures(): IntegrationFeature[] {
return [
IntegrationFeature.Test,
IntegrationFeature.SearchTrack,
IntegrationFeature.SearchAlbum,
IntegrationFeature.SearchArtist,
]
}
getIcon(props: any) {
return <StoreLinkIcon whichStore={IntegrationWith.YoutubeMusic} {...props} />
}
providesStoreLink() {
return IntegrationWith.YoutubeMusic;
}
async test(testParams: {}) {
// Test songs
let response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/search?q=${encodeURIComponent('No One Knows Queens Of The Stone Age')}`);
let text = await response.text();
let results: any = parseItems(extractInitialData(text)).tracks;
if (!Array.isArray(results) || results.length === 0 || !results[0] || results[0].title !== "No One Knows") {
throw new Error("Test failed; No One Knows was not correctly identified.");
}
// Test albums
response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/search?q=${encodeURIComponent('Songs For The Deaf Queens Of The Stone Age')}`);
text = await response.text();
results = parseItems(extractInitialData(text)).albums;
if (!Array.isArray(results) || results.length === 0 || !results[0] || results[0].name !== "Songs For The Deaf") {
throw new Error("Test failed; Songs For The Deaf was not correctly identified.");
}
// Test artists
response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/search?q=${encodeURIComponent('Queens Of The Stone Age')}`);
text = await response.text();
results = parseItems(extractInitialData(text)).artists;
if (!Array.isArray(results) || results.length === 0 || !results[0] || results[0].name !== "Queens Of The Stone Age") {
throw new Error("Test failed; Queens Of The Stone Age was not correctly identified.");
}
}
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 parseItems(extractInitialData(text)).tracks;
}
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`);
let text = await response.text();
return parseItems(extractInitialData(text)).albums;
}
async searchArtist(query: string, limit: number): Promise<IntegrationArtist[]> {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/search?q=${encodeURIComponent(query)}`);
let text = await response.text();
return parseItems(extractInitialData(text)).artists;
}
}

@ -0,0 +1,154 @@
import * as serverApi from '../../api';
export async function getArtists(filter: string) {
const query = filter.length > 0 ? {
prop: serverApi.QueryElemProperty.artistName,
propOperand: filter,
propOperator: serverApi.QueryFilterOp.Like,
} : {};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
artistOffset: 0,
artistLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const names: string[] = json.artists.map((elem: any) => { return elem.name; });
return [...new Set(names)];
})();
}
export async function getAlbums(filter: string) {
const query = filter.length > 0 ? {
prop: serverApi.QueryElemProperty.albumName,
propOperand: filter,
propOperator: serverApi.QueryFilterOp.Like,
} : {};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
albumOffset: 0,
albumLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const names: string[] = json.albums.map((elem: any) => { return elem.name; });
return [...new Set(names)];
})();
}
export async function getSongTitles(filter: string) {
const query = filter.length > 0 ? {
prop: serverApi.QueryElemProperty.songTitle,
propOperand: filter,
propOperator: serverApi.QueryFilterOp.Like,
} : {};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const titles: string[] = json.songs.map((elem: any) => { return elem.title; });
return [...new Set(titles)];
})();
}
export async function getTags() {
var q: serverApi.QueryRequest = {
query: {},
offsetsLimits: {
tagOffset: 0,
tagLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((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,37 +1,24 @@
import * as serverApi from '../../api/api';
export enum QueryFor {
Artists = "artists",
Albums = "albums",
Tags = "tags",
Tracks = "tracks",
}
import * as serverApi from '../../api';
export enum QueryLeafBy {
ArtistName = "artistName",
ArtistId = "artistId",
AlbumName = "albumName",
AlbumId = "albumId",
TagInfo = "tagInfo",
TagId = "tagId",
TrackName = "trackName",
TrackId = "trackId",
StoreLinks = "storeLinks",
NotApplicable = "n/a", // Some query nodes don't need an operand.
ArtistName = 0,
AlbumName,
TagInfo,
SongTitle
}
export enum QueryLeafOp {
Equals = "equals",
Like = "like",
Placeholder = "placeholder", // Special op which indicates that this leaf is not filled in yet.
Equals = 0,
Like,
Placeholder, // Special op which indicates that this leaf is not filled in yet.
}
export interface TagQueryInfo {
matchIds: number[],
fullName: string[],
matchIds: number[],
}
export function isTagQueryInfo(e: any): e is TagQueryInfo {
return (typeof e === 'object') && 'matchIds' in e && 'fullName' in e;
return (typeof e === 'object') && 'fullName' in e && 'matchIds' in e;
}
export type QueryLeafOperand = string | number | TagQueryInfo;
@ -46,9 +33,8 @@ export function isLeafElem(q: QueryElem): q is QueryLeafElem {
}
export enum QueryNodeOp {
And = "AND",
Or = "OR",
Not = "NOT",
And = 0,
Or,
}
export interface QueryNodeElem {
@ -74,53 +60,8 @@ export function queryAnd(...args: QueryElem[]) {
};
}
export function queryNot(arg: QueryElem) {
return {
operands: [arg],
nodeOp: QueryNodeOp.Not,
}
}
export type QueryElem = QueryLeafElem | QueryNodeElem;
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null):
serverApi.QueryElemProperty | null {
return {
[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.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.StoreLinks]:
(queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks :
(queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks :
(queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks :
null,
[QueryLeafBy.TagInfo]: null,
[QueryLeafBy.NotApplicable]: null,
}[l];
}
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null):
serverApi.QueryLeafOp | null {
return {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
[QueryLeafOp.Placeholder]: null,
}[l];
}
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null):
serverApi.QueryNodeOp | null {
return {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
}[l];
}
// Take a query and add placeholders. The placeholders are empty
// leaves. They should be placed so that all possible node combinations
// from the existing nodes could have an added combinational leaf.
@ -128,12 +69,12 @@ function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null):
// placeholders for all AND/OR combinations with existing nodes.
export function addPlaceholders(
q: QueryElem | null,
inNode: null | QueryNodeOp,
inNode: null | QueryNodeOp.And | QueryNodeOp.Or,
): QueryElem {
const makePlaceholder: () => QueryElem = () => {
const makePlaceholder = () => {
return {
a: QueryLeafBy.NotApplicable,
a: 0,
leafOp: QueryLeafOp.Placeholder,
b: ""
}
@ -142,24 +83,11 @@ export function addPlaceholders(
const otherOp: Record<QueryNodeOp, QueryNodeOp> = {
[QueryNodeOp.And]: QueryNodeOp.Or,
[QueryNodeOp.Or]: QueryNodeOp.And,
[QueryNodeOp.Not]: QueryNodeOp.Not, // TODO fix this
}
if (q == null) {
return makePlaceholder();
} else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not &&
isLeafElem(q.operands[0]) &&
inNode !== null) {
// Not only modifies its sub-node, so this is handled like a leaf.
return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] };
} else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not &&
isLeafElem(q.operands[0]) &&
inNode === null) {
// Not only modifies its sub-node, so this is handled like a leaf.
return { operands: [q, makePlaceholder()], nodeOp: QueryNodeOp.And };
} else if (isNodeElem(q) && q.nodeOp != QueryNodeOp.Not) {
// Combinational operators.
} else if (isNodeElem(q)) {
var operands = q.operands.map((op: any, idx: number) => {
return addPlaceholders(op, q.nodeOp);
});
@ -172,11 +100,11 @@ export function addPlaceholders(
return newBlock;
}
} else if (isLeafElem(q) &&
q.leafOp !== QueryLeafOp.Placeholder &&
q.leafOp != QueryLeafOp.Placeholder &&
inNode !== null) {
return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] };
} else if (isLeafElem(q) &&
q.leafOp !== QueryLeafOp.Placeholder &&
q.leafOp != QueryLeafOp.Placeholder &&
inNode === null) {
return {
operands: [
@ -195,7 +123,7 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
var newOperands: QueryElem[] = [];
q.operands.forEach((op: any) => {
if (isLeafElem(op) && op.leafOp === QueryLeafOp.Placeholder) {
if (isLeafElem(op) && op.leafOp == QueryLeafOp.Placeholder) {
return;
}
const newOp = removePlaceholders(op);
@ -204,77 +132,70 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
}
})
if (newOperands.length === 0) {
if (newOperands.length == 0) {
return null;
}
if ((newOperands.length === 1 && [QueryNodeOp.Or, QueryNodeOp.And].includes(q.nodeOp))) {
if (newOperands.length == 1) {
return newOperands[0];
}
return { operands: newOperands, nodeOp: q.nodeOp };
} else if (q && isLeafElem(q) && q.leafOp === QueryLeafOp.Placeholder) {
} else if (q && isLeafElem(q) && q.leafOp == QueryLeafOp.Placeholder) {
return null;
}
return q;
}
// Note: null means an invalidating node. It should make the whole query invalid, so it should
// be propagated to the root.
export function simplify(q: QueryElem | null, queryFor: QueryFor | null): QueryElem | null {
export function simplify(q: QueryElem | null): QueryElem | null {
if (q && isNodeElem(q)) {
var newOperands: (QueryElem | null)[] = q.operands.map((op: QueryElem) => simplify(op, queryFor));
if (newOperands.filter((op: QueryElem | null) => op === null).length > 0) {
console.log("nullifying op:", q, queryFor)
return null;
var newOperands: QueryElem[] = [];
q.operands.forEach((o: QueryElem) => {
const s = simplify(o);
if (s !== null) { newOperands.push(s); }
})
if (newOperands.length === 0) { return null; }
if (newOperands.length === 1) { return newOperands[0]; }
return { operands: newOperands, nodeOp: q.nodeOp };
}
return { operands: newOperands as QueryElem[], nodeOp: q.nodeOp };
return q;
}
// Nullify any queries that contain operations which are invalid
// for the current queried object type.
if (q && isLeafElem(q) && queryFor !== null &&
(mapToServerLeafOp(q.leafOp, queryFor) === null ||
mapToServerProperty(q.a, queryFor) === null)) {
return null;
export function toApiQuery(q: QueryElem) : serverApi.Query {
const propsMapping: any = {
[QueryLeafBy.SongTitle]: serverApi.QueryElemProperty.songTitle,
[QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
[QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
}
return q;
const leafOpsMapping: any = {
[QueryLeafOp.Equals]: serverApi.QueryFilterOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryFilterOp.Like,
}
const nodeOpsMapping: any = {
[QueryNodeOp.And]: serverApi.QueryElemOp.And,
[QueryNodeOp.Or]: serverApi.QueryElemOp.Or,
}
export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Query {
if(isLeafElem(q) && isTagQueryInfo(q.b)) {
// Special case for tag queries by ID
const r: serverApi.QueryElem = {
prop: serverApi.QueryElemProperty.tagId,
propOperator: serverApi.QueryLeafOp.In,
propOperator: serverApi.QueryFilterOp.In,
propOperand: q.b.matchIds,
}
return r;
} else if(isLeafElem(q)) {
// If the property to operate on is non-existent
// (e.g. store links for a tag query), throw.
let a = mapToServerProperty(q.a, queryFor);
let op = mapToServerLeafOp(q.leafOp, queryFor);
if (a === null || op === null) {
console.log("Error details:", q, queryFor);
throw 'Found a null leaf in query tree. Was it simplified first?';
}
// "Regular" queries
const r: serverApi.QueryElem = {
prop: a,
propOperator: op,
prop: propsMapping[q.a],
propOperator: leafOpsMapping[q.leafOp],
propOperand: q.b,
}
return r;
} else if(isNodeElem(q)) {
let op = mapToServerNodeOp(q.nodeOp, queryFor);
if (op === null) {
throw 'Found a null node in query tree. Was it simplified first?'
}
const r = {
children: q.operands.map((op: any) => toApiQuery(op, queryFor)),
childrenOperator: op
children: q.operands.map((op: any) => toApiQuery(op)),
childrenOperator: nodeOpsMapping[q.nodeOp]
}
return r;
}

@ -1,43 +1,56 @@
import * as serverApi from '../api/api';
import backendRequest from './backend/request';
import * as serverApi from '../api';
export async function modifyTrack(id: number, change: serverApi.PatchTrackRequest) {
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) {
const requestOpts = {
method: 'PATCH',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.PatchTrackEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save track changes: " + response.statusText);
throw new Error("Failed to save song changes: " + response.statusText);
}
}
export async function modifyArtist(id: number, change: serverApi.PatchArtistRequest) {
export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequest) {
const requestOpts = {
method: 'PATCH',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.PatchArtistEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save tag changes: " + response.statusText);
}
}
export async function saveArtistChanges(id: number, change: serverApi.ModifyArtistRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString());
const response = await fetch((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.PatchAlbumRequest) {
export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbumRequest) {
const requestOpts = {
method: 'PATCH',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.PatchAlbumEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText);
}

@ -0,0 +1,28 @@
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));
},
}

@ -1,134 +0,0 @@
// Note: Based on https://usehooks.com/useAuth/
import React, { useState, useContext, createContext, ReactFragment } from "react";
import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../api/api';
export interface AuthUser {
id: number,
email: string,
icon: ReactFragment,
}
export interface Auth {
user: AuthUser | null,
signout: () => void,
signin: (email: string, password: string) => Promise<AuthUser>,
signup: (email: string, password: string) => Promise<void>,
};
const authContext = createContext<Auth>({
user: null,
signout: () => { },
signin: (email: string, password: string) => {
throw new Error("Auth object not initialized.");
},
signup: (email: string, password: string) => {
throw new Error("Auth object not initialized.");
},
});
export function ProvideAuth(props: { children: any }) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>;
}
export const useAuth = () => {
return useContext(authContext);
};
function persistAuth(auth: AuthUser | null) {
let s = window.sessionStorage;
if(auth === null) {
s.removeItem('userId');
s.removeItem('userEmail');
return;
}
s.setItem('userId', auth.id.toString());
s.setItem('userEmail', auth.email);
// TODO icon
}
function loadAuth(): AuthUser | null {
let s = window.sessionStorage;
let id = s.getItem('userId');
let email = s.getItem('userEmail');
if (id && email) {
return {
id: parseInt(id),
email: email,
icon: <PersonIcon />
}
}
return null;
}
function useProvideAuth() {
const [user, setUser] = useState<AuthUser | null>(loadAuth());
// TODO: password maybe shouldn't be encoded into the URL.
const signin = (email: string, password: string) => {
return (async () => {
const urlBase = (process.env.REACT_APP_BACKEND || "") + serverApi.LoginEndpoint;
const url = `${urlBase}?username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url, { method: "POST" });
const json = await response.json();
if (!("userId" in json)) {
throw new Error("No UserID received from login.");
}
const user = {
id: json.userId,
email: email,
icon: <PersonIcon />,
}
setUser(user);
persistAuth(user);
return user;
})();
};
const signup = (email: string, password: string) => {
return (async () => {
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
password: password,
})
};
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.RegisterUserEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Failed to register user.")
}
})();
};
const signout = () => {
console.log("Signing out.");
setUser(null);
persistAuth(null);
return (async () => {
const url = (process.env.REACT_APP_BACKEND || "") + serverApi.LogoutEndpoint;
const response = await fetch(url, { method: "POST" });
if (!response.ok) {
throw new Error("Failed to log out.");
}
})();
};
// Return the user object and auth methods
return {
user,
signin,
signup,
signout,
};
}

@ -1,14 +0,0 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
process.env.REACT_APP_BACKEND,
createProxyMiddleware({
target: 'http://localhost:5000',
changeOrigin: true,
pathRewrite: {
'^/api': '/', // remove base path
},
}),
);
};

File diff suppressed because it is too large Load Diff

73
package-lock.json generated

@ -101,16 +101,6 @@
"yargs": "^12.0.5"
}
},
"cookie": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
},
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@ -128,14 +118,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz",
"integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@ -176,33 +158,6 @@
"strip-eof": "^1.0.0"
}
},
"express-session": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz",
"integrity": "sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q==",
"requires": {
"cookie": "0.4.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.0",
"uid-safe": "~2.1.5"
},
"dependencies": {
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
},
"safe-buffer": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
}
}
},
"find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
@ -309,11 +264,6 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@ -343,11 +293,6 @@
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
},
"on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -411,11 +356,6 @@
"json-parse-better-errors": "^1.0.1"
}
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
},
"path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
@ -445,11 +385,6 @@
"once": "^1.3.1"
}
},
"random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs="
},
"read-pkg": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-4.0.1.tgz",
@ -609,14 +544,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz",
"integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q=="
},
"uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"requires": {
"random-bytes": "~1.0.0"
}
},
"validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",

@ -8,7 +8,6 @@
"start": "npm run-script dev"
},
"dependencies": {
"concurrently": "^4.0.1",
"express-session": "^1.17.1"
"concurrently": "^4.0.1"
}
}

@ -0,0 +1,2 @@
venv
mobileclient.cred

File diff suppressed because it is too large Load Diff

@ -0,0 +1,176 @@
#!/usr/bin/env python3
from gmusicapi import Mobileclient
import argparse
import sys
import requests
import json
creds_path=sys.path[0] + '/mobileclient.cred'
def authenticate(api):
creds = api.perform_oauth(storage_filepath=creds_path, open_browser=False)
def uploadLibrary(mudbase_api, songs):
# Helpers
def getArtistStoreIds(song):
if 'artistId' in song:
return [ song['artistId'][0] ]
return [];
def getSongStoreIds(song):
if 'storeId' in song:
return [ song['storeId'] ]
return []
# Create GPM import tag
gpmTagIdResponse = requests.post(mudbase_api + '/tag', data = {
'name': 'GPM Import'
}).json()
gpmTagId = gpmTagIdResponse['id']
print(f"Created tag \"GPM Import\", response: {gpmTagIdResponse}")
# Create the root genre tag
genreRootResponse = requests.post(mudbase_api + '/tag', data = {
'name': 'Genre'
}).json()
genreRootTagId = genreRootResponse['id']
print(f"Created tag \"Genre\", response: {genreRootResponse}")
# For keeping track what we have already created.
storedArtists = dict()
storedAlbums = dict()
storedGenreTags = dict()
for song in songs:
# TODO: check if these items already exist
# Determine artist properties.
artist = {
'name': song['artist'],
'storeLinks': [ 'https://play.google.com/music/m' + id for id in getArtistStoreIds(song) ],
'tagIds': [ gpmTagId ]
} if 'artist' in song else None
# Determine album properties.
album = {
'name': song['album'],
'tagIds': [ gpmTagId ]
} if 'album' in song else None
# Determine genre properties.
genre = {
'name': song['genre'],
'parentId': genreRootTagId
} if 'genre' in song else None
# Upload artist if not already done
artistId = None
if artist:
for key,value in storedArtists.items():
if value == artist:
artistId = key
break
if not artistId:
response = requests.post(mudbase_api + '/artist', json = artist).json()
artistId = response['id']
print(f"Created artist \"{artist['name']}\", response: {response}")
storedArtists[artistId] = artist
# Upload album if not already done
albumId = None
if album:
for key,value in storedAlbums.items():
if value == album:
albumId = key
break
if not albumId:
response = requests.post(mudbase_api + '/album', json = album).json()
albumId = response['id']
print(f"Created album \"{album['name']}\", response: {response}")
storedAlbums[albumId] = album
# Upload genre if not already done
genreTagId = None
if genre:
for key,value in storedGenreTags.items():
if value == genre:
genreTagId = key
break
if not genreTagId:
response = requests.post(mudbase_api + '/tag', json = genre).json()
genreTagId = response['id']
print(f"Created genre tag \"Genre / {genre['name']}\", response: {response}")
storedGenreTags[genreTagId] = genre
# Upload the song itself
tagIds = [ gpmTagId ]
if genreTagId:
tagIds.append(genreTagId)
_song = {
'title': song['title'],
'artistIds': [ artistId ] if artistId != None else [],
'albumIds': [ albumId ] if albumId != None else [],
'tagIds': tagIds,
'storeLinks': [ 'https://play.google.com/music/m/' + id for id in getSongStoreIds(song) ],
}
response = requests.post(mudbase_api + '/song', json = _song).json()
print(f"Created song \"{song['title']}\" with artist ID {artistId}, album ID {albumId}, response: {response}")
def getData(api):
return {
"songs": api.get_all_songs(),
"playlists": api.get_all_user_playlist_contents()
}
def getSongs(data):
# Get songs from library
songs = [] #data['songs']
# Append songs from playlists
for playlist in data['playlists']:
for track in playlist['tracks']:
if 'track' in track:
songs.append(track['track'])
# Uniquify by using a dict. After all, same song may appear in
# multiple playlists.
sI = lambda song: song['artist'] + '-' + song['title'] if 'artist' in song and 'title' in song else 'z'
return list(dict((sI(song), song) for song in songs).values())
api = Mobileclient()
parser = argparse.ArgumentParser(description="Import Google Music library into MudBase.")
parser.add_argument('--authenticate', help="Generate credentials for authentication", action="store_true")
parser.add_argument('--store-to', help="Store GPM library to JSON for later upload", action='store', dest='store_to')
parser.add_argument('--load-from', help="Load GPM library from JSON for upload", action='store', dest='load_from')
parser.add_argument('--mudbase_api', help="Address for the Mudbase back-end API to upload to", action='store', dest='mudbase_api')
args = parser.parse_args()
if args.authenticate:
authenticate(api)
data = None
# Determine whether we need to log in to GPM and get songs
if args.store_to or (not args.load_from and args.mudbase_api):
api.oauth_login(Mobileclient.FROM_MAC_ADDRESS, oauth_credentials=creds_path)
data = getData(api)
# Determine whether to save to a file
if args.store_to:
with open(args.store_to, 'w') as outfile:
json.dump(data, outfile, sort_keys=True, indent=2)
# Determine whether to load from a file
if args.load_from:
with open(args.load_from, 'r') as f:
data = json.load(f)
songs = getSongs(data)
print(f"Found {len(songs)} songs.")
if args.mudbase_api:
api.oauth_login(Mobileclient.FROM_MAC_ADDRESS, oauth_credentials=creds_path)
uploadLibrary(args.mudbase_api, songs)

@ -0,0 +1,3 @@
gmusicapi
argparse
requests

@ -1,32 +1,30 @@
const bodyParser = require('body-parser');
import * as api from '../client/src/api/api';
import * as api from '../client/src/api';
import Knex from 'knex';
import { DataEndpoints } from './endpoints/Data';
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 { CreateSongEndpointHandler } from './endpoints/CreateSongEndpointHandler';
import { CreateArtistEndpointHandler } from './endpoints/CreateArtistEndpointHandler';
import { QueryEndpointHandler } from './endpoints/QueryEndpointHandler';
import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetailsEndpointHandler'
import { SongDetailsEndpointHandler } from './endpoints/SongDetailsEndpointHandler';
import { ModifyArtistEndpointHandler } from './endpoints/ModifyArtistEndpointHandler';
import { ModifySongEndpointHandler } from './endpoints/ModifySongEndpointHandler';
import { CreateTagEndpointHandler } from './endpoints/CreateTagEndpointHandler';
import { ModifyTagEndpointHandler } from './endpoints/ModifyTagEndpointHandler';
import { TagDetailsEndpointHandler } from './endpoints/TagDetailsEndpointHandler';
import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbumEndpointHandler';
import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbumEndpointHandler';
import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetailsEndpointHandler';
import * as endpointTypes from './endpoints/types';
import { sha512 } from 'js-sha512';
import { createIntegrations } from './integrations/integrations';
// For authentication
var passport = require('passport');
var Strategy = require('passport-local').Strategy;
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.handleErrorsInEndpoint)
.catch(endpointTypes.catchUnhandledErrors)
.catch((_e:endpointTypes.EndpointError) => {
let e:endpointTypes.EndpointError = _e;
console.log("Error handling request: ", e.message);
console.log("Error handling request: ", e.internalMessage);
res.sendStatus(e.httpStatus);
})
console.log("Finished handling", req.method, "@", req.url);
@ -34,104 +32,27 @@ const invokeHandler = (handler: endpointTypes.EndpointHandler, knex: Knex) => {
}
const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
app.use(bodyParser.json({ limit: "10mb" }));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Set up auth. See: https://github.com/passport/express-4.x-local-example.git
passport.use(new Strategy(
function (email: string, password: string, cb: any) {
(async () => {
try {
const user = await knex.select(['email', 'passwordHash', 'id'])
.from('users')
.where({ 'email': email })
.then((users: any) => users[0]);
if (!user) { cb(null, false); }
if (sha512(password) != user.passwordHash) {
return cb(null, false);
}
return cb(null, user);
} catch (error) { cb(error); }
})();
}));
passport.serializeUser(function (user: any, cb: any) {
cb(null, user.id);
});
passport.deserializeUser(function (id: number, cb: any) {
(async () => {
try {
const user = await knex.select(['email', 'passwordHash', 'id'])
.from('users')
.where({ 'id': id })
.then((users: any) => users[0]);
if (!user) { cb(null, false); }
return cb(null, user);
} catch (error) { cb(error); }
})();
});
var session = require('express-session')
var MemoryStore = require('memorystore')(session)
app.use(session({
secret: 'EA9q5cukt7UFhN',
resave: false,
saveUninitialized: false,
cookie: { maxAge: 86400000 }, //24h
store: new MemoryStore({
checkPeriod: 86400000, //24h
}),
}));
app.use(passport.initialize());
app.use(passport.session());
const _invoke = (handler: endpointTypes.EndpointHandler) => {
const invokeWithKnex = (handler: endpointTypes.EndpointHandler) => {
return invokeHandler(handler, knex);
}
const checkLogin = () => {
return function (req: any, res: any, next: any) {
if (!req.isAuthenticated || !req.isAuthenticated()) {
return res
.status(401)
.json({ reason: "NotLoggedIn" })
.send();
}
next();
}
}
// Set up integration proxies
app.use(apiBaseUrl + '/integrations', checkLogin(), createIntegrations(knex, apiBaseUrl));
// Set up auth endpoints
app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => {
res.status(200).send({ userId: req.user.id });
});
app.post(apiBaseUrl + api.LogoutEndpoint, function (req: any, res: any) {
req.logout();
res.status(200).send();
});
// Set up other endpoints
[
albumEndpoints,
artistEndpoints,
tagEndpoints,
trackEndpoints,
integrationEndpoints,
userEndpoints,
queryEndpoints,
DataEndpoints,
].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));
}
})
});
// Set up REST API endpoints
app.post(apiBaseUrl + api.CreateSongEndpoint, invokeWithKnex(CreateSongEndpointHandler));
app.post(apiBaseUrl + api.QueryEndpoint, invokeWithKnex(QueryEndpointHandler));
app.post(apiBaseUrl + api.CreateArtistEndpoint, invokeWithKnex(CreateArtistEndpointHandler));
app.put(apiBaseUrl + api.ModifyArtistEndpoint, invokeWithKnex(ModifyArtistEndpointHandler));
app.put(apiBaseUrl + api.ModifySongEndpoint, invokeWithKnex(ModifySongEndpointHandler));
app.get(apiBaseUrl + api.SongDetailsEndpoint, invokeWithKnex(SongDetailsEndpointHandler));
app.get(apiBaseUrl + api.ArtistDetailsEndpoint, invokeWithKnex(ArtistDetailsEndpointHandler));
app.post(apiBaseUrl + api.CreateTagEndpoint, invokeWithKnex(CreateTagEndpointHandler));
app.put(apiBaseUrl + api.ModifyTagEndpoint, invokeWithKnex(ModifyTagEndpointHandler));
app.get(apiBaseUrl + api.TagDetailsEndpoint, invokeWithKnex(TagDetailsEndpointHandler));
app.post(apiBaseUrl + api.CreateAlbumEndpoint, invokeWithKnex(CreateAlbumEndpointHandler));
app.put(apiBaseUrl + api.ModifyAlbumEndpoint, invokeWithKnex(ModifyAlbumEndpointHandler));
app.get(apiBaseUrl + api.AlbumDetailsEndpoint, invokeWithKnex(AlbumDetailsEndpointHandler));
}
export { SetupApp }

@ -1,359 +0,0 @@
import Knex from "knex";
import { Album, AlbumRefs, Id, Name, AlbumDetails, StoreLinks, Tag, TagParentId, Track, Artist } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types";
import { makeNotFoundError } from "./common";
import { transform } from "typescript";
var _ = require('lodash');
// Returns an album with details, or null if not found.
export async function getAlbum(id: number, userId: number, knex: Knex):
Promise<(Album & AlbumDetails & StoreLinks & Name)> {
// Start transfers for tracks, tags and artists.
// Also request the album itself.
const tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> =
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)
.then((tags: (Id & Name & TagParentId)[]) =>
tags.map((tag : (Id & Name & TagParentId)) =>
{ return {...tag, mbApi_typename: "tag"}}
))
);
const tracksPromise: Promise<(Track & Id)[]> =
knex.select(['id', 'name', 'storeLinks'])
.from('tracks')
.where({ 'album': id })
.then((tracks: any) => tracks.map((track: any) => {
return { id: track['id'], mbApi_typename: "track" }
}))
const artistsPromise: Promise<(Artist & Id & Name & StoreLinks)[]> =
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)
.then((artists: (Id & Name & StoreLinks)[]) =>
artists.map((artist : (Id & Name & StoreLinks)) =>
{ return {...artist, mbApi_typename: "artist"}}
))
);
const albumPromise: Promise<(Album & Name & StoreLinks) | undefined> =
knex.select('name', 'storeLinks')
.from('albums')
.where({ 'user': userId })
.where({ id: id })
.then((albums: any) => { return { ...albums[0], mbApi_typename: 'album' }});
// 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 || [],
tags: tags || [],
tracks: tracks || [],
storeLinks: asJson(album['storeLinks'] || []),
};
}
throw makeNotFoundError();
}
// Returns the id of the created album.
export async function createAlbum(userId: number, album: (Album & Name & AlbumRefs), knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => {
// 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 ((!_.isEqual(artists.sort(), (album.artistIds || []).sort())) ||
(!_.isEqual(tags.sort(), (album.tagIds || []).sort())) ||
(!_.isEqual(tracks.sort(), (album.trackIds || []).sort()))) {
throw makeNotFoundError();
}
// 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 direct links.
if (tracks && tracks.length) {
await trx('tracks')
.update({ album: albumId })
.whereIn('id', tracks);
}
console.log('created album', album, ', ID ', albumId);
return albumId;
})
}
export async function modifyAlbum(userId: number, albumId: number, album: Album, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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')
.whereIn('id', album.artistIds)
.then((as: any) => as.map((a: any) => a['id']))
: (async () => undefined)();
// Start retrieving tracks if we are modifying those.
const trackIdsPromise: Promise<number[] | undefined> =
album.trackIds ?
trx.select('id')
.from('tracks')
.whereIn('album', album.trackIds)
.then((as: any) => as.map((a: any) => a['id']))
: (async () => undefined)();
// Start retrieving tags if we are modifying those.
const tagIdsPromise =
album.tagIds ?
trx.select('id')
.from('tags')
.whereIn('id', album.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) :
(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 ((album.artistIds && (!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort()))) ||
(album.tagIds && (!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort()))) ||
(album.trackIds && (!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort()))) ||
!oldAlbum) {
throw makeNotFoundError();
}
// 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 by setting their references to null.
const removeUnlinkedTracks = tracks ? trx('tracks')
.where({ 'album': albumId })
.whereNotIn('id', album.trackIds || [])
.update({ 'album': null }) : 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')
.where({ 'album': albumId })
.then((as: any) => as.map((a: any) => a['id']))
.then((doneTrackIds: number[]) => {
// Get the set of tracks that are not yet linked
const toLink = (tracks || []).filter((id: number) => {
return !doneTrackIds.includes(id);
});
// Link them
return trx('tracks')
.update({ album: albumId })
.whereIn('id', toLink);
}) : 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;
})
}
export async function deleteAlbum(userId: number, albumId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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) {
throw makeNotFoundError();
}
// 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 by setting their references to null.
const deleteTracksPromise: Promise<any> =
trx.update({ 'album': null })
.from('tracks')
.where({ 'album': 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]);
})
}

@ -1,376 +0,0 @@
import Knex from "knex";
import { Artist, ArtistDetails, Tag, Track, TagParentId, Id, Name, StoreLinks, Album, ArtistRefs } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types";
import { makeNotFoundError } from "./common";
var _ = require('lodash')
// Returns an artist with details, or null if not found.
export async function getArtist(id: number, userId: number, knex: Knex):
Promise<(Artist & ArtistDetails & Name & StoreLinks)> {
// Start transfers for tags and albums.
// Also request the artist itself.
const tagsPromise: Promise<(Tag & Name & Id & TagParentId)[]> =
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)
.then((tags: (Id & Name & TagParentId)[]) =>
tags.map((tag : (Id & Name & TagParentId)) =>
{ return {...tag, mbApi_typename: "tag"}}
))
);
const albumsPromise: Promise<(Album & Name & Id & StoreLinks)[]> =
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('albums')
.whereIn('id', ids)
.then((albums: (Id & Name & StoreLinks)[]) =>
albums.map((tag : (Id & Name & StoreLinks)) =>
{ return {...tag, mbApi_typename: "album"}}
))
);
const tracksPromise: Promise<(Track & Id & Name & StoreLinks)[]> =
knex.select('trackId')
.from('tracks_artists')
.where({ 'artistId': id })
.then((tracks: any) => tracks.map((track: any) => track['trackId']))
.then((ids: number[]) =>
knex.select(['id', 'name', 'storeLinks'])
.from('tracks')
.whereIn('id', ids)
.then((tracks: (Id & Name & StoreLinks)[]) =>
tracks.map((tag : (Id & Name & StoreLinks)) =>
{ return {...tag, mbApi_typename: "track"}}
))
);
const artistPromise: Promise<(Artist & Name & StoreLinks) | undefined> =
knex.select('name', 'storeLinks')
.from('artists')
.where({ 'user': userId })
.where({ id: id })
.then((artists: any) => { return { ...artists[0], mbApi_typename: 'artist' } });
// Wait for the requests to finish.
const [artist, tags, albums, tracks] =
await Promise.all([artistPromise, tagsPromise, albumsPromise, tracksPromise]);
if (artist && artist['name']) {
return {
mbApi_typename: 'artist',
name: artist['name'],
albums: albums,
tags: tags,
tracks: tracks,
storeLinks: asJson(artist['storeLinks'] || []),
};
}
throw makeNotFoundError();
}
// Returns the id of the created artist.
export async function createArtist(userId: number, artist: (Artist & ArtistRefs & Name), knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => {
// 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 tracks.
const trackIdsPromise: Promise<number[]> =
trx.select('id')
.from('tracks')
.where({ 'user': userId })
.whereIn('id', artist.trackIds || [])
.then((as: any) => as.map((a: any) => a['id']));
// Start retrieving tags.
const tagIdsPromise: Promise<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, tracks] = await Promise.all([albumIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all artists and tags we need.
if (!_.isEqual(albums.sort(), (artist.albumIds || []).sort()) ||
!_.isEqual(tags.sort(), (artist.tagIds || []).sort()) ||
!_.isEqual(tracks.sort(), (artist.trackIds || []).sort())) {
throw makeNotFoundError();
}
// Create the artist.
const artistId = (await trx('artists')
.insert({
name: artist.name,
storeLinks: JSON.stringify(artist.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the albums via the linking table.
if (albums && albums.length) {
await trx('artists_albums').insert(
albums.map((albumId: number) => {
return {
albumId: albumId,
artistId: artistId,
}
})
)
}
// Link the tracks via the linking table.
if (tracks && tracks.length) {
await trx('tracks_artists').insert(
tracks.map((trackId: number) => {
return {
trackId: trackId,
artistId: artistId,
}
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('artists_tags').insert(
tags.map((tagId: number) => {
return {
artistId: artistId,
tagId: tagId,
}
})
)
}
console.log('created artist', artist, ', ID ', artistId);
return artistId;
})
}
export async function modifyArtist(userId: number, artistId: number, artist: Artist, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// Start retrieving the artist itself.
const artistIdPromise: Promise<number | undefined | null> =
trx.select('id')
.from('artists')
.where({ 'user': userId })
.where({ id: artistId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : null);
// Start retrieving albums if we are modifying those.
const albumIdsPromise: Promise<number[] | undefined | null> =
artist.albumIds ?
trx.select('id')
.from('albums')
.whereIn('id', artist.albumIds)
.then((as: any) => as.map((a: any) => a['id']))
: (async () => null)();
// Start retrieving tracks if we are modifying those.
const trackIdsPromise: Promise<number[] | undefined> =
artist.trackIds ?
trx.select('id')
.from('tracks')
.whereIn('id', artist.trackIds)
.then((as: any) => as.map((a: any) => a['id']))
: (async () => null)();
// Start retrieving tags if we are modifying those.
const tagIdsPromise =
artist.tagIds ?
trx.select('id')
.from('tags')
.whereIn('id', artist.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) :
(async () => null)();
// Wait for the requests to finish.
var [oldArtist, albums, tags, tracks] = await Promise.all([artistIdPromise, albumIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all objects we need.
if ((albums === undefined || !_.isEqual((albums || []).sort(), (artist.albumIds || []).sort())) ||
(tags === undefined || !_.isEqual((tags || []).sort(), (artist.tagIds || []).sort())) ||
(tracks === undefined || !_.isEqual((tracks || []).sort(), (artist.trackIds || []).sort())) ||
!oldArtist) {
throw makeNotFoundError();
}
// Modify the artist.
var update: any = {};
if ("name" in artist) { update["name"] = artist.name; }
if ("storeLinks" in artist) { update["storeLinks"] = JSON.stringify(artist.storeLinks || []); }
const modifyArtistPromise = trx('artists')
.where({ 'user': userId })
.where({ 'id': artistId })
.update(update)
// Remove unlinked albums.
const removeUnlinkedAlbums = albums ? trx('artists_albums')
.where({ 'artistId': artistId })
.whereNotIn('albumId', artist.albumIds || [])
.delete() : undefined;
// Remove unlinked tracks.
const removeUnlinkedTracks = tracks ? trx('tracks_artists')
.where({ 'artistId': artistId })
.whereNotIn('trackId', artist.trackIds || [])
.delete() : undefined;
// Remove unlinked tags.
const removeUnlinkedTags = tags ? trx('artists_tags')
.where({ 'artistId': artistId })
.whereNotIn('tagId', artist.tagIds || [])
.delete() : undefined;
// Link new albums.
const addAlbums = albums ? trx('artists_albums')
.where({ 'artistId': artistId })
.then((as: any) => as.map((a: any) => a['albumId']))
.then((doneAlbumIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = (albums || []).filter((id: number) => {
return !doneAlbumIds.includes(id);
});
const insertObjects = toLink.map((albumId: number) => {
return {
artistId: artistId,
albumId: albumId,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('artists_albums').insert(obj)
)
);
}) : undefined;
// Link new tracks.
const addTracks = tracks ? trx('tracks_artists')
.where({ 'artistId': artistId })
.then((as: any) => as.map((a: any) => a['trackId']))
.then((doneTrackIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = (tracks || []).filter((id: number) => {
return !doneTrackIds.includes(id);
});
const insertObjects = toLink.map((trackId: number) => {
return {
artistId: artistId,
trackId: trackId,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('tracks_artists').insert(obj)
)
);
}) : undefined;
// Link new tags.
const addTags = tags ? trx('artists_tags')
.where({ 'artistId': artistId })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
artistId: artistId,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('artists_tags').insert(obj)
)
);
}) : undefined;
// Wait for all operations to finish.
await Promise.all([
modifyArtistPromise,
removeUnlinkedAlbums,
removeUnlinkedTags,
removeUnlinkedTracks,
addAlbums,
addTags,
addTracks,
]);
return;
})
}
export async function deleteArtist(userId: number, artistId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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) {
throw makeNotFoundError();
}
// 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]);
})
}

@ -1,211 +0,0 @@
import Knex from "knex";
import { Track, TrackRefs, Id, Name, StoreLinks, Album, AlbumRefs, Artist, ArtistRefs, Tag, TagParentId, isTrackRefs, isAlbumRefs, DBImportResponse, IDMappings } 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";
let _ = require('lodash');
export async function exportDB(userId: number, knex: Knex): Promise<api.DBDataFormat> {
// First, retrieve all the objects without taking linking tables into account.
// Fetch the links separately.
let tracksPromise: Promise<(Track & Id & Name & StoreLinks & TrackRefs)[]> =
knex.select('id', 'name', 'storeLinks', 'album')
.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.album,
artistIds: [],
tagIds: [],
}
}));
let albumsPromise: Promise<(Album & Id & Name & StoreLinks & AlbumRefs)[]> =
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<(Artist & Id & Name & ArtistRefs & StoreLinks)[]> =
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<(Tag & Id & Name & TagParentId)[]> =
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: (Track & Id & TrackRefs)) => t.id === trackId)?.artistIds.push(artistId);
artists.find((a: (Artist & Id & ArtistRefs)) => a.id === artistId)?.trackIds.push(trackId);
})
tracks.forEach((t: (Track & Id & TrackRefs)) => {
albums.find((a: (Album & Id & AlbumRefs)) => t.albumId && a.id === t.albumId)?.trackIds.push(t.id);
})
tracksTags.forEach((v: [number, number]) => {
let [trackId, tagId] = v;
tracks.find((t: (Track & Id & TrackRefs)) => t.id === trackId)?.tagIds.push(tagId);
})
artistsTags.forEach((v: [number, number]) => {
let [artistId, tagId] = v;
artists.find((t: (Artist & Id & ArtistRefs)) => t.id === artistId)?.tagIds.push(tagId);
})
albumsTags.forEach((v: [number, number]) => {
let [albumId, tagId] = v;
albums.find((t: (Album & Id & AlbumRefs)) => t.id === albumId)?.tagIds.push(tagId);
})
artistsAlbums.forEach((v: [number, number]) => {
let [albumId, artistId] = v;
artists.find((t: (Artist & Id & ArtistRefs)) => t.id === artistId)?.albumIds.push(albumId);
albums.find((t: (Album & Id & AlbumRefs)) => t.id === albumId)?.artistIds.push(artistId);
})
return {
tracks: tracks,
albums: albums,
artists: artists,
tags: tags,
}
}
export async function importDB(userId: number, db: api.DBDataFormat, knex: Knex): Promise<DBImportResponse> {
// Store the ID mappings in this record.
let maps: IDMappings = {
tracks: {},
artists: {},
albums: {},
tags: {},
}
// Insert items one by one, remapping the IDs as we go.
for(const tag of db.tags) {
let _tag = {
..._.omit(tag, 'id'),
parentId: tag.parentId ? maps.tags[tag.parentId] : null,
}
maps.tags[tag.id] = await createTag(userId, _tag, knex);
}
for(const artist of db.artists) {
maps.artists[artist.id] = await createArtist(userId, {
..._.omit(artist, 'id'),
tagIds: artist.tagIds.map((id: number) => maps.tags[id]),
trackIds: [],
albumIds: [],
}, knex);
}
for(const album of db.albums) {
maps.albums[album.id] = await createAlbum(userId, {
..._.omit(album, 'id'),
tagIds: album.tagIds.map((id: number) => maps.tags[id]),
artistIds: album.artistIds.map((id: number) => maps.artists[id]),
trackIds: [],
}, knex);
}
for(const track of db.tracks) {
maps.tracks[track.id] = await createTrack(userId, {
..._.omit(track, 'id'),
tagIds: track.tagIds.map((id: number) => maps.tags[id]),
artistIds: track.artistIds.map((id: number) => maps.artists[id]),
albumId: track.albumId ? maps.albums[track.albumId] : null,
}, knex);
}
return maps;
}
export async function wipeDB(userId: number, knex: Knex) {
return await knex.transaction(async (trx) => {
await Promise.all([
trx('tracks').where({ 'user': userId }).del(),
trx('artists').where({ 'user': userId }).del(),
trx('albums').where({ 'user': userId }).del(),
trx('tags').where({ 'user': userId }).del(),
])
})
}

@ -1,107 +0,0 @@
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';
import { makeNotFoundError } from './common';
export async function createIntegration(userId: number, integration: api.IntegrationDataWithSecret, knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => {
// Create the new integration.
var dbIntegration: 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(dbIntegration)
.returning('id') // Needed for Postgres
)[0];
return integrationId;
})
}
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 {
throw makeNotFoundError();
}
}
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) => {
// 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) {
throw makeNotFoundError();
}
// Delete the integration.
await trx('integrations')
.where({ 'user': userId, 'id': integrationId })
.del();
})
}
export async function modifyIntegration(userId: number, id: number, integration: PartialIntegrationData, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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) {
throw makeNotFoundError();
}
// 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)
})
}

@ -1,513 +0,0 @@
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from '../endpoints/types';
import Knex from 'knex';
import asJson from '../lib/asJson';
import { Tag, TagDetails, Id, Name, Artist, Track, TrackDetails, Album, StoreLinks } from '../../client/src/api/api';
export function toApiTag(dbObj: any): api.QueryResponseTagDetails {
return {
mbApi_typename: "tag",
id: dbObj['tags.id'],
name: dbObj['tags.name'],
parentId: dbObj['tags.parentId'],
parent: dbObj.parent ? toApiTag(dbObj.parent) : undefined,
};
}
export function toApiArtist(dbObj: any): api.QueryResponseArtistDetails {
return {
mbApi_typename: "artist",
id: dbObj['artists.id'],
name: dbObj['artists.name'],
storeLinks: asJson(dbObj['artists.storeLinks']),
};
}
export function toApiTrack(dbObj: any, artists: any[], tags: any[], albums: any[]): api.QueryResponseTrackDetails {
return {
mbApi_typename: "track",
id: 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: albums.length > 0 ? toApiAlbum(albums[0]) : null,
}
}
export function toApiAlbum(dbObj: any): api.QueryResponseAlbumDetails {
return {
mbApi_typename: "album",
id: 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.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 | undefined {
var res: string | undefined = undefined;
linkingTables.forEach((row: any) => {
if (row[0].includes(a) && row[0].includes(b)) {
res = row[1];
}
})
return res;
}
// To keep track of linking columns between objects.
const linkingColumns: any = [
[[ObjectType.Track, ObjectType.Album], 'tracks.album'],
]
function getLinkingColumn(a: ObjectType, b: ObjectType): string | undefined {
var res: string | undefined = undefined;
linkingColumns.forEach((row: any) => {
if (row[0].includes(a) && row[0].includes(b)) {
res = row[1];
}
})
return res;
}
// To keep track of ID fields used in linking tables.
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 linkColumn = getLinkingColumn(base, other);
const baseTable = objectTables[base];
const otherTable = objectTables[other];
if (linkTable) {
return knexQuery
.join(linkTable, { [baseTable + '.id']: linkTable + '.' + linkingTableIdNames[base] })
.join(otherTable, { [otherTable + '.id']: linkTable + '.' + linkingTableIdNames[other] });
} else if (linkColumn) {
return knexQuery
.join(otherTable, { [linkColumn]: otherTable + '.id' });
}
}
enum WhereType {
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.name as tracks.name', 'tracks.storeLinks as tracks.storeLinks', 'tracks.album as tracks.album'],
[ObjectType.Artist]: ['artists.id as artists.id', 'artists.name as artists.name', 'artists.storeLinks as artists.storeLinks'],
[ObjectType.Album]: ['albums.id as albums.id', 'albums.name as albums.name', 'albums.storeLinks as albums.storeLinks'],
[ObjectType.Tag]: ['tags.id as tags.id', 'tags.name as tags.name', 'tags.parentId as tags.parentId']
};
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) ? 'name' : '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 table = objectTables[base];
const otherTable = objectTables[linked];
const maybeLinkingTable = getLinkingTable(base, linked);
const maybeLinkingColumn = getLinkingColumn(base, linked);
const columns = objectColumns[linked];
console.log(table, otherTable, maybeLinkingTable, maybeLinkingColumn);
if (maybeLinkingTable) {
await Promise.all(baseIds.map((baseId: number) => {
return knex.select(columns).groupBy(otherTable + '.id').from(otherTable)
.join(maybeLinkingTable, { [maybeLinkingTable + '.' + linkingTableIdNames[linked]]: otherTable + '.id' })
.where({ [otherTable + '.user']: userId })
.where({ [maybeLinkingTable + '.' + linkingTableIdNames[base]]: baseId })
.then((others: any) => { result[baseId] = others; })
}))
} else if (maybeLinkingColumn) {
await Promise.all(baseIds.map((baseId: number) => {
return knex.select(columns).groupBy(otherTable + '.id').from(otherTable)
.join(table, { [maybeLinkingColumn]: otherTable + '.id' })
.where({ [otherTable + '.user']: userId })
.where({ [table + '.id']: baseId })
.then((others: any) => { result[baseId] = others; })
}))
} else {
throw new Error('canno link objects.')
}
console.log("Query results for", baseIds, ":", result);
return result;
}
// 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,
albumOffset || 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.")
}
}
console.log("Query response:", response)
return response;
}

@ -1,307 +0,0 @@
import Knex from "knex";
import { isConstructorDeclaration } from "typescript";
import * as api from '../../client/src/api/api';
import { Tag, TagParentId, TagDetails, Id, Name } from "../../client/src/api/api";
import { DBError, DBErrorKind } from "../endpoints/types";
import { makeNotFoundError } from "./common";
export async function getTagChildrenRecursive(id: number,
userId: number,
trx: any,
visited: number[] = [], // internal, for cycle detection
): Promise<number[]> {
// check for cycles, these are not allowed.
// a cycle would be if the same ID occurs more than once in the visited set.
if ((new Set<number>(visited)).size < visited.length) {
throw new Error('cyclic tag dependency')
}
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, [...visited, id])
);
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: (Tag & Name & TagParentId), knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => {
// If applicable, retrieve the parent tag.
const maybeMatches: any[] | null =
tag.parentId ?
(await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': tag.parentId })) :
null;
// Check if the parent was found, if applicable.
if (tag.parentId && maybeMatches && !maybeMatches.length) {
throw makeNotFoundError();
}
// Create the new tag.
var newTag: any = {
name: tag.name,
user: userId,
};
if (tag.parentId) {
newTag['parentId'] = tag.parentId;
}
const tagId = (await trx('tags')
.insert(newTag)
.returning('id') // Needed for Postgres
)[0];
console.log('created tag', tag, ', ID ', tagId);
return tagId;
})
}
export async function deleteTag(userId: number, tagId: number, knex: Knex) {
await knex.transaction(async (trx) => {
// Start retrieving any child tags.
const childTagsPromise =
getTagChildrenRecursive(tagId, userId, trx);
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: tagId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, children] = await Promise.all([tagPromise, childTagsPromise]);
// Merge all IDs.
const toDelete = [tag, ...children];
// Check that we found all objects we need.
if (!tag) {
throw makeNotFoundError();
}
// 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])
})
}
export async function getTag(userId: number, tagId: number, knex: Knex): Promise<(Tag & TagDetails & Name)> {
const tagPromise: Promise<(Tag & Id & Name & TagParentId) | null> =
knex.select(['id', 'name', 'parentId'])
.from('tags')
.where({ 'user': userId })
.where({ 'id': tagId })
.then((r: (Id & Name & TagParentId)[] | undefined) => r ? r[0] : null)
.then((r: (Id & Name & TagParentId) | null) => {
if (r) {
return { ...r, mbApi_typename: 'tag'};
}
return null;
})
const parentPromise: Promise<(Tag & Id & Name & TagDetails) | null> =
tagPromise
.then((r: (Tag & Id & Name & TagParentId) | null) =>
(r && r.parentId) ? (
getTag(userId, r.parentId, knex)
.then((rr: (Tag & Name & TagDetails) | null) =>
rr ? { ...rr, id: r.parentId || 0 } : null)
) : null
)
const [maybeTag, maybeParent] = await Promise.all([tagPromise, parentPromise]);
if (maybeTag) {
let result: (Tag & Name & TagDetails) = {
mbApi_typename: "tag",
name: maybeTag.name,
parent: maybeParent,
}
return result;
} else {
throw makeNotFoundError();
}
}
export async function modifyTag(userId: number, tagId: number, tag: Tag, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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.length ? ts.map((t: any) => t['tagId']) : null) :
(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)
// Start retrieving all current children. This is to prevent
// cycles.
const childrenPromise = getTagChildrenRecursive(tagId, userId, trx);
// Wait for the requests to finish.
var [dbTag, parent, children] = await Promise.all([tagPromise, parentTagIdPromise, childrenPromise]);
// Check that modifying this will not cause a dependency cycle.
if (tag.parentId && [...children, tagId].includes(tag.parentId)) {
const e: DBError = {
name: "DBError",
kind: DBErrorKind.ResourceConflict,
message: 'Modifying this tag would cause a tag parent cycle.',
};
throw e;
}
// Check that we found all objects we need.
if ((tag.parentId && !parent) ||
!dbTag) {
throw makeNotFoundError();
}
// Modify the tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': tagId })
.update({
name: tag.name,
parentId: tag.parentId || null,
})
})
}
export async function mergeTag(userId: number, fromId: number, toId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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)
// Start retrieving any children of the 'from' tag
const childrenPromise = getTagChildrenRecursive(fromId, userId, trx);
// Wait for the requests to finish.
var [fromTagId, toTagId, fromChildren] = await Promise.all([fromTagIdPromise, toTagIdPromise, childrenPromise]);
// Check that we found all objects we need.
if (!fromTagId || !toTagId) {
throw makeNotFoundError();
}
// Check that we are not merging to itself and not merging with its own children
if (fromTagId === toTagId) {
const e: DBError = {
name: "DBError",
kind: DBErrorKind.ResourceConflict,
message: 'Cannot merge a tag into itself',
};
throw e;
}
if (fromChildren.includes(toId)) {
const e: DBError = {
name: "DBError",
kind: DBErrorKind.ResourceConflict,
message: 'Cannot merge a tag with one of its children.',
};
throw e;
}
// Move any child tags under the new tag.
const cPromise = trx('tags')
.where({ 'user': userId })
.where({ 'parentId': fromId })
.update({ 'parentId': toId });
// Assign new tag ID to any objects referencing the to-be-merged tag.
let doReplacement = async (table: string, otherIdField: string) => {
// Store the items referencing the old tag.
let referencesFrom = await trx(table)
.select([otherIdField])
.where({ 'tagId': fromId })
.then((r: any) => r.map((result: any) => result[otherIdField]))
// Store the items referencing the new tag.
let referencesTo = await trx(table)
.select([otherIdField])
.where({ 'tagId': toId })
.then((r: any) => r.map((result: any) => result[otherIdField]))
let referencesEither = [...referencesFrom, ...referencesTo];
let referencesBoth = referencesEither.filter((id: number) => referencesFrom.includes(id) && referencesTo.includes(id));
let referencesOnlyFrom = referencesEither.filter((id: number) => referencesFrom.includes(id) && !referencesTo.includes(id));
// For items referencing only the from tag, update to the to tag.
await trx(table)
.whereIn(otherIdField, referencesOnlyFrom)
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
// For items referencing both, just remove the reference to the from tag.
await trx(table)
.whereIn(otherIdField, referencesBoth)
.where({ 'tagId': fromId })
.delete();
}
const sPromise = doReplacement('tracks_tags', 'trackId');
const arPromise = doReplacement('artists_tags', 'artistId');
const alPromise = doReplacement('albums_tags', 'albumId');
await Promise.all([sPromise, arPromise, alPromise, cPromise]);
// Delete the original tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': fromId })
.del();
})
}

@ -1,346 +0,0 @@
import Knex from "knex";
import { Track, TrackRefs, TrackDetails, Id, Name, StoreLinks, Tag, Album, Artist, TagParentId } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types";
import { makeNotFoundError } from "./common";
var _ = require('lodash')
// Returns an track with details, or null if not found.
export async function getTrack(id: number, userId: number, knex: Knex):
Promise<Track & Name & StoreLinks & TrackDetails> {
// Start transfers for tracks, tags and artists.
// Also request the track itself.
const tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> =
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)
.then((tags: (Id & Name & TagParentId)[]) =>
tags.map((tag : (Id & Name & TagParentId)) =>
{ return {...tag, mbApi_typename: "tag"}}
))
);
const artistsPromise: Promise<(Artist & Id & Name & StoreLinks)[]> =
knex.select('artistId')
.from('tracks_artists')
.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)
.then((artists: (Id & Name & StoreLinks)[]) =>
artists.map((artist : (Id & Name & StoreLinks)) =>
{ return {...artist, mbApi_typename: "artist"}}
))
);
const trackPromise: Promise<(Track & StoreLinks & Name) | undefined> =
knex.select('name', 'storeLinks', 'album')
.from('tracks')
.where({ 'user': userId })
.where({ id: id })
.then((tracks: any) => { return {
name: tracks[0].name,
storeLinks: tracks[0].storeLinks,
albumId: tracks[0].album,
mbApi_typename: 'track'
}});
const albumPromise: Promise<(Album & Name & Id & StoreLinks) | 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], mpApi_typename: 'album' }
: 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 || [],
tags: tags || [],
album: album || null,
storeLinks: asJson(track['storeLinks'] || []),
};
} else {
throw makeNotFoundError();
}
}
// Returns the id of the created track.
export async function createTrack(userId: number, track: (Track & Name & TrackRefs), knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => {
// 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> =
track.albumId ?
trx.select('id')
.from('albums')
.where({ 'user': userId, 'id': track.albumId })
.then((albums: any) => albums.map((album: any) => album['id']))
.then((ids: number[]) =>
ids.length > 0 ? ids[0] : (() => null)()
) :
(async () => null)();
// Wait for the requests to finish.
var [artists, tags, album] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdPromise]);
// Check that we found all artists and tags we need.
if (!_.isEqual((artists as number[]).sort(), track.artistIds.sort()) ||
(!_.isEqual((tags as number[]).sort(), track.tagIds.sort())) ||
(track.albumId && (album === null))) {
throw makeNotFoundError();
}
// Create the track.
const trackId = (await trx('tracks')
.insert({
name: track.name,
storeLinks: JSON.stringify(track.storeLinks || []),
user: userId,
album: album || null,
})
.returning('id') // Needed for Postgres
)[0];
// Link the artists via the linking table.
if (artists && artists.length) {
await trx('tracks_artists').insert(
artists.map((artistId: number) => {
return {
artistId: artistId,
trackId: trackId,
}
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('tracks_tags').insert(
tags.map((tagId: number) => {
return {
trackId: trackId,
tagId: tagId,
}
})
)
}
console.log('created track', track, ', ID ', trackId);
return trackId;
})
}
export async function modifyTrack(userId: number, trackId: number, track: Track, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// 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('id')
.from('artists')
.whereIn('id', track.artistIds)
.then((as: any) => as.map((a: any) => a['id']))
: (async () => undefined)();
// Start retrieving tags if we are modifying those.
const tagIdsPromise =
track.tagIds ?
trx.select('id')
.from('tags')
.whereIn('id', track.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) :
(async () => undefined)();
// Start retrieving album if we are modifying that.
const albumIdPromise =
track.albumId ?
trx.select('id')
.from('albums')
.where({ 'user': userId })
.where({ id: track.albumId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) :
(async () => undefined)();
// Wait for the requests to finish.
var [oldTrack, artists, tags, album] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise, albumIdPromise]);;
console.log("Patch track: ", oldTrack, artists, tags, album);
// Check that we found all objects we need.
if ((track.artistIds && (!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort()))) ||
(track.tagIds && (!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort()))) ||
(track.albumId && !album) ||
!oldTrack) {
throw makeNotFoundError();
}
// 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["album"] = track.albumId; }
const modifyTrackPromise = trx('tracks')
.where({ 'user': userId })
.where({ 'id': trackId })
.update(update)
// Remove unlinked artists.
const removeUnlinkedArtists = artists ? trx('tracks_artists')
.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('tracks_artists')
.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('tracks_artists').insert(obj)
)
);
}) : undefined;
// Link new tags.
const addTags = tags ? trx('tracks_tags')
.where({ 'trackId': trackId })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
trackId: trackId,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('tracks_tags').insert(obj)
)
);
}) : undefined;
// Wait for all operations to finish.
await Promise.all([
modifyTrackPromise,
removeUnlinkedArtists,
removeUnlinkedTags,
addArtists,
addTags,
]);
return;
})
}
export async function deleteTrack(userId: number, trackId: number, knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
// FIXME remove
let tracks = await trx.select('id', 'name')
.from('tracks');
console.log("All tracks:", tracks);
// 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) {
throw makeNotFoundError();
}
// Start deleting artist associations with the track.
const deleteArtistsPromise: Promise<any> =
trx.delete()
.from('tracks_artists')
.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]);
})
}

@ -1,35 +0,0 @@
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) => {
// 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;
})
}

@ -1,10 +0,0 @@
import { DBError, DBErrorKind } from "../endpoints/types";
export function makeNotFoundError() {
const e: DBError = {
name: "DBError",
kind: DBErrorKind.ResourceNotFound,
message: 'Not all to-be-linked resources were found.',
};
return e;
}

@ -1,116 +0,0 @@
import * as api from '../../client/src/api/api';
import { EndpointError, EndpointHandler, handleErrorsInEndpoint } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
import { createAlbum, deleteAlbum, getAlbum, modifyAlbum } from '../db/Album';
import { GetArtist } from './Artist';
export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const { id: userId } = req.user;
let id = parseInt(req.params.id);
try {
const maybeAlbum: api.GetAlbumResponse | null =
await getAlbum(id, userId, knex);
if (maybeAlbum) {
await res.send(maybeAlbum);
} else {
await res.status(404).send({});
}
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostAlbumRequest(req.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PostAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.PostAlbumRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Album ", reqObject);
try {
let id = await createAlbum(userId, reqObject, knex);
res.status(200).send({ id: id });
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPutAlbumRequest(req.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PutAlbum request',
httpStatus: 400
};
throw e;
}
const reqObject: api.PutAlbumRequest = req.body;
const { id: userId } = req.user;
let id = parseInt(req.params.id);
console.log("User ", userId, ": Put Album ", reqObject);
try {
await modifyAlbum(userId, id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const PatchAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPatchAlbumRequest(req.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PatchAlbum request',
httpStatus: 400
};
throw e;
}
const reqObject: api.PatchAlbumRequest = req.body;
const { id: userId } = req.user;
let id = parseInt(req.params.id);
console.log("User ", userId, ": Patch Album ", reqObject);
try {
await modifyAlbum(userId, id, reqObject, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
export const DeleteAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
const { id: userId } = req.user;
let id = parseInt(req.params.id);
console.log("User ", userId, ": Delete Album ", id);
try {
await deleteAlbum(userId, id, knex);
res.status(200).send();
} catch (e) {
handleErrorsInEndpoint(e);
}
}
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 ],
];

@ -0,0 +1,59 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const AlbumDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkAlbumDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid AlbumDetails request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
try {
// Start transfers for songs, tags and artists.
// Also request the album itself.
const tagIdsPromise = knex.select('tagId')
.from('albums_tags')
.where({ 'albumId': req.params.id })
.then((tags: any) => {
return tags.map((tag: any) => tag['tagId'])
});
const songIdsPromise = knex.select('songId')
.from('songs_albums')
.where({ 'albumId': req.params.id })
.then((songs: any) => {
return songs.map((song: any) => song['songId'])
});
const artistIdsPromise = knex.select('artistId')
.from('artists_albums')
.where({ 'albumId': req.params.id })
.then((artists: any) => {
return artists.map((artist: any) => artist['artistId'])
});
const albumPromise = knex.select('name', 'storeLinks')
.from('albums')
.where({ id: req.params.id })
.then((albums: any) => albums[0]);
// Wait for the requests to finish.
const [album, tags, songs, artists] =
await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]);
// Respond to the request.
console.log("ALBUM: ", album);
const response: api.AlbumDetailsResponse = {
name: album['name'],
artistIds: artists,
tagIds: tags,
songIds: songs,
storeLinks: asJson(album['storeLinks']),
};
await res.send(response);
} catch (e) {
catchUnhandledErrors(e);
}
}

@ -1,112 +0,0 @@
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) => {
const { id: userId } = req.user;
let id = parseInt(req.params.id);
try {
let artist = await getArtist(id, userId, knex);
await res.status(200).send(artist);
} catch (e) {
handleErrorsInEndpoint(e)
}
}
export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkPostArtistRequest(req.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PostArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.PostArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create artist ", reqObject)
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.checkPutArtistRequest(req.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PutArtist request',
httpStatus: 400
};
throw e;
}
const reqObject: api.PutArtistRequest = req.body;
const { id: userId } = req.user;
let id = parseInt(req.params.id);
console.log("User ", userId, ": Put Artist ", reqObject);
try {
await modifyArtist(userId, 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.body)) {
const e: EndpointError = {
name: "EndpointError",
message: 'Invalid PatchArtist request',
httpStatus: 400
};
throw e;
}
const reqObject: api.PatchArtistRequest = req.body;
const { id: userId } = req.user;
let id = parseInt(req.params.id);
console.log("User ", userId, ": Patch Artist ", reqObject);
try {
await modifyArtist(userId, 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;
let id = parseInt(req.params.id);
console.log("User ", userId, ": Delete Artist ", id);
try {
await deleteArtist(userId, 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 ],
];

@ -0,0 +1,35 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const ArtistDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkArtistDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ArtistDetails request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
try {
const tagIds = Array.from(new Set((await knex.select('tagId')
.from('artists_tags')
.where({ 'artistId': req.params.id })
).map((tag: any) => tag['tagId'])));
const results = await knex.select(['id', 'name', 'storeLinks'])
.from('artists')
.where({ 'id': req.params.id });
const response: api.ArtistDetailsResponse = {
name: results[0].name,
tagIds: tagIds,
storeLinks: asJson(results[0].storeLinks),
}
await res.send(response);
} catch (e) {
catchUnhandledErrors(e)
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save