Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
a183252b88 | 5 years ago |
139 changed files with 83387 additions and 34419 deletions
@ -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 @@ |
||||
{} |
||||
@ -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
@ -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, |
||||
} |
||||
|
Before Width: | Height: | Size: 907 B |
|
Before Width: | Height: | Size: 696 B |
@ -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" |
||||
> |
||||
<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> |
||||
{label} |
||||
<Box ml={1}> |
||||
<IconButton |
||||
size="small" |
||||
color="inherit" |
||||
onClick={onClose} |
||||
> |
||||
<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 onCloseAddMenu = () => { |
||||
setAddMenuAnchorEl(null); |
||||
}; |
||||
const onCloseUserMenu = () => { |
||||
setUserMenuPos(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 |
||||
value={props.selectedTab} |
||||
onChange={(e: any, val: AppBarTab) => { |
||||
let path = appBarTabProps[val].path |
||||
path && history.push(appBarTabProps[val].path) |
||||
}} |
||||
variant="scrollable" |
||||
scrollButtons="auto" |
||||
> |
||||
{Object.keys(appBarTabProps).map((tab: any, idx: number) => <MuiTab |
||||
label={appBarTabProps[tab].label} |
||||
value={idx} |
||||
disabled={!(appBarTabProps[tab].path) && idx !== props.selectedTab} |
||||
/>)} |
||||
</Tabs>} |
||||
<Box m={0.5} display="flex" alignItems="center"> |
||||
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> |
||||
</Box> |
||||
{auth.user && <IconButton |
||||
color="primary" |
||||
onClick={(e: any) => { onOpenUserMenu(e) }} |
||||
>{auth.user.icon}</IconButton>} |
||||
<Tabs |
||||
value={props.selectedTab} |
||||
onChange={(e: any, v: number) => props.setSelectedTab(v)} |
||||
variant="scrollable" |
||||
scrollButtons="auto" |
||||
> |
||||
{props.tabLabels.map((l: string, idx: number) => <Tab |
||||
label={l} |
||||
value={idx} |
||||
onClose={() => props.onCloseTab(idx)} |
||||
/>)} |
||||
</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; |
||||
} |
||||
return prev; |
||||
}, undefined); |
||||
if(url.includes('play.google.com')) { |
||||
return ExternalStore.GooglePlayMusic; |
||||
} |
||||
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} />; |
||||
switch(whichStore) { |
||||
case ExternalStore.GooglePlayMusic: |
||||
return <GPMIcon {...restProps}/>; |
||||
default: |
||||
throw new Error("Unknown external store: " + whichStore) |
||||
} |
||||
|
||||
@ -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} / </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,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}> </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>- </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} / </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,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
@ -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,137 +1,58 @@ |
||||
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) => { |
||||
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((_e: endpointTypes.EndpointError) => { |
||||
let e: endpointTypes.EndpointError = _e; |
||||
console.log("Error handling request: ", e.message); |
||||
res.sendStatus(e.httpStatus); |
||||
}) |
||||
.catch(endpointTypes.catchUnhandledErrors) |
||||
.catch((_e:endpointTypes.EndpointError) => { |
||||
let e:endpointTypes.EndpointError = _e; |
||||
console.log("Error handling request: ", e.internalMessage); |
||||
res.sendStatus(e.httpStatus); |
||||
}) |
||||
console.log("Finished handling", req.method, "@", req.url); |
||||
}; |
||||
} |
||||
|
||||
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…
Reference in new issue