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 React from 'react'; |
||||||
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton, Typography, Menu, MenuItem } from '@material-ui/core'; |
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core'; |
||||||
import SearchIcon from '@material-ui/icons/Search'; |
import CloseIcon from '@material-ui/icons/Close'; |
||||||
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; |
import AddIcon from '@material-ui/icons/Add'; |
||||||
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; |
import AddTabMenu, { NewTabProps } from './AddTabMenu'; |
||||||
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'; |
|
||||||
|
|
||||||
export enum AppBarTab { |
export interface IProps { |
||||||
Browse = 0, |
tabLabels: string[], |
||||||
Query, |
selectedTab: number, |
||||||
Manage, |
setSelectedTab: (n: number) => void, |
||||||
|
onCloseTab: (idx: number) => void, |
||||||
|
onAddTab: (w: NewTabProps) => void, |
||||||
} |
} |
||||||
|
|
||||||
export const appBarTabProps: Record<any, any> = { |
export interface TabProps { |
||||||
[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, |
|
||||||
onClose: () => void, |
onClose: () => void, |
||||||
}) { |
} |
||||||
let auth = useAuth(); |
|
||||||
let history = useHistory(); |
|
||||||
|
|
||||||
const pos = props.open && props.position ? |
export function Tab(props: any) { |
||||||
{ left: props.position[0], top: props.position[1] } |
const { onClose, label, ...restProps } = props; |
||||||
: { left: 0, top: 0 } |
|
||||||
|
|
||||||
return <Menu |
const labelElem = <Box |
||||||
open={props.open} |
display="flex" |
||||||
anchorReference="anchorPosition" |
alignItems="center" |
||||||
anchorPosition={pos} |
justifyContent="center" |
||||||
keepMounted |
> |
||||||
onClose={props.onClose} |
{label} |
||||||
|
<Box ml={1}> |
||||||
|
<IconButton |
||||||
|
size="small" |
||||||
|
color="inherit" |
||||||
|
onClick={onClose} |
||||||
> |
> |
||||||
<Box p={2}> |
<CloseIcon /> |
||||||
{auth.user?.email || "Unknown user"} |
</IconButton> |
||||||
<MenuItem |
|
||||||
onClick={() => { |
|
||||||
props.onClose(); |
|
||||||
history.replace('/settings') |
|
||||||
}} |
|
||||||
>User Settings</MenuItem> |
|
||||||
<MenuItem |
|
||||||
onClick={() => { |
|
||||||
props.onClose(); |
|
||||||
props.onLogout(); |
|
||||||
}} |
|
||||||
>Sign out</MenuItem> |
|
||||||
</Box> |
</Box> |
||||||
</Menu> |
</Box>; |
||||||
|
|
||||||
|
return <MuiTab |
||||||
|
label={labelElem} |
||||||
|
{...restProps} |
||||||
|
/> |
||||||
} |
} |
||||||
|
|
||||||
export default function AppBar(props: { |
export default function AppBar(props: IProps) { |
||||||
selectedTab: AppBarTab | null |
const [addMenuAnchorEl, setAddMenuAnchorEl] = React.useState<null | HTMLElement>(null); |
||||||
}) { |
|
||||||
let history = useHistory(); |
|
||||||
let auth = useAuth(); |
|
||||||
|
|
||||||
const [userMenuPos, setUserMenuPos] = React.useState<null | number[]>(null); |
const onOpenAddMenu = (event: any) => { |
||||||
const onOpenUserMenu = (e: any) => { |
setAddMenuAnchorEl(event.currentTarget); |
||||||
setUserMenuPos([e.clientX, e.clientY]) |
|
||||||
}; |
}; |
||||||
const onCloseUserMenu = () => { |
const onCloseAddMenu = () => { |
||||||
setUserMenuPos(null); |
setAddMenuAnchorEl(null); |
||||||
|
}; |
||||||
|
const onAddTab = (w: NewTabProps) => { |
||||||
|
props.onAddTab(w); |
||||||
}; |
}; |
||||||
|
|
||||||
return <> |
return <> |
||||||
<MuiAppBar position="static" style={{ background: 'grey' }}> |
<MuiAppBar position="static" style={{ background: 'grey' }}> |
||||||
<Box display="flex" alignItems="center"> |
<Box display="flex" alignItems="center"> |
||||||
<Link to="/"> |
|
||||||
<Box m={0.5} display="flex" alignItems="center"> |
<Box m={0.5} display="flex" alignItems="center"> |
||||||
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> |
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> |
||||||
</Box> |
</Box> |
||||||
</Link> |
<Tabs |
||||||
<Box flexGrow={1}> |
|
||||||
{auth.user && <Tabs |
|
||||||
value={props.selectedTab} |
value={props.selectedTab} |
||||||
onChange={(e: any, val: AppBarTab) => { |
onChange={(e: any, v: number) => props.setSelectedTab(v)} |
||||||
let path = appBarTabProps[val].path |
|
||||||
path && history.push(appBarTabProps[val].path) |
|
||||||
}} |
|
||||||
variant="scrollable" |
variant="scrollable" |
||||||
scrollButtons="auto" |
scrollButtons="auto" |
||||||
> |
> |
||||||
{Object.keys(appBarTabProps).map((tab: any, idx: number) => <MuiTab |
{props.tabLabels.map((l: string, idx: number) => <Tab |
||||||
label={appBarTabProps[tab].label} |
label={l} |
||||||
value={idx} |
value={idx} |
||||||
disabled={!(appBarTabProps[tab].path) && idx !== props.selectedTab} |
onClose={() => props.onCloseTab(idx)} |
||||||
/>)} |
/>)} |
||||||
</Tabs>} |
</Tabs> |
||||||
</Box> |
<IconButton color="inherit" onClick={onOpenAddMenu}><AddIcon /></IconButton> |
||||||
{auth.user && <IconButton |
|
||||||
color="primary" |
|
||||||
onClick={(e: any) => { onOpenUserMenu(e) }} |
|
||||||
>{auth.user.icon}</IconButton>} |
|
||||||
</Box> |
</Box> |
||||||
</MuiAppBar> |
</MuiAppBar> |
||||||
<UserMenu |
<AddTabMenu |
||||||
position={userMenuPos} |
anchorEl={addMenuAnchorEl} |
||||||
open={userMenuPos !== null} |
onClose={onCloseAddMenu} |
||||||
onClose={onCloseUserMenu} |
onCreateTab={onAddTab} |
||||||
onLogout={auth.signout} |
|
||||||
/> |
/> |
||||||
</> |
</> |
||||||
} |
} |
||||||
@ -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 React from 'react'; |
||||||
import { IntegrationWith, IntegrationUrls } from '../../api/api'; |
|
||||||
import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; |
import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; |
||||||
import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg'; |
|
||||||
import { ReactComponent as YoutubeMusicIcon } from '../../assets/youtubemusic_icon.svg'; |
export enum ExternalStore { |
||||||
|
GooglePlayMusic = "GPM", |
||||||
|
} |
||||||
|
|
||||||
export interface IProps { |
export interface IProps { |
||||||
whichStore: IntegrationWith, |
whichStore: ExternalStore, |
||||||
} |
} |
||||||
|
|
||||||
export function whichStore(url: string) { |
export function whichStore(url: string) { |
||||||
return Object.keys(IntegrationUrls).reduce((prev: string | undefined, cur: string) => { |
if(url.includes('play.google.com')) { |
||||||
if(url.includes(IntegrationUrls[cur as IntegrationWith])) { |
return ExternalStore.GooglePlayMusic; |
||||||
return cur; |
|
||||||
} |
} |
||||||
return prev; |
return undefined; |
||||||
}, undefined); |
|
||||||
} |
} |
||||||
|
|
||||||
export default function StoreLinkIcon(props: any) { |
export default function StoreLinkIcon(props: any) { |
||||||
const { whichStore, style, ...restProps } = props; |
const { whichStore, ...restProps } = props; |
||||||
|
|
||||||
let realStyle = (style === undefined) ? |
|
||||||
{ height: '40px', width: '40px' } : style; |
|
||||||
|
|
||||||
switch (whichStore) { |
switch(whichStore) { |
||||||
case IntegrationWith.GooglePlayMusic: |
case ExternalStore.GooglePlayMusic: |
||||||
return <GPMIcon {...restProps} style={realStyle} />; |
return <GPMIcon {...restProps}/>; |
||||||
case IntegrationWith.Spotify: |
|
||||||
return <SpotifyIcon {...restProps} style={realStyle} />; |
|
||||||
case IntegrationWith.YoutubeMusic: |
|
||||||
return <YoutubeMusicIcon {...restProps} style={realStyle} />; |
|
||||||
default: |
default: |
||||||
throw new Error("Unknown external store: " + whichStore) |
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 * as serverApi from '../api'; |
||||||
import backendRequest from './backend/request'; |
|
||||||
|
|
||||||
export async function modifyTrack(id: number, change: serverApi.PatchTrackRequest) { |
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) { |
||||||
const requestOpts = { |
const requestOpts = { |
||||||
method: 'PATCH', |
method: 'PUT', |
||||||
headers: { 'Content-Type': 'application/json' }, |
headers: { 'Content-Type': 'application/json' }, |
||||||
body: JSON.stringify(change), |
body: JSON.stringify(change), |
||||||
}; |
}; |
||||||
|
|
||||||
const endpoint = serverApi.PatchTrackEndpoint.replace(":id", id.toString()); |
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString()); |
||||||
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) |
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) |
||||||
if(!response.ok) { |
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 = { |
const requestOpts = { |
||||||
method: 'PATCH', |
method: 'PUT', |
||||||
headers: { 'Content-Type': 'application/json' }, |
headers: { 'Content-Type': 'application/json' }, |
||||||
body: JSON.stringify(change), |
body: JSON.stringify(change), |
||||||
}; |
}; |
||||||
|
|
||||||
const endpoint = serverApi.PatchArtistEndpoint.replace(":id", id.toString()); |
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString()); |
||||||
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) |
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) { |
if(!response.ok) { |
||||||
throw new Error("Failed to save artist changes: " + response.statusText); |
throw new Error("Failed to save artist changes: " + response.statusText); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
export async function modifyAlbum(id: number, change: serverApi.PatchAlbumRequest) { |
export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbumRequest) { |
||||||
const requestOpts = { |
const requestOpts = { |
||||||
method: 'PATCH', |
method: 'PUT', |
||||||
headers: { 'Content-Type': 'application/json' }, |
headers: { 'Content-Type': 'application/json' }, |
||||||
body: JSON.stringify(change), |
body: JSON.stringify(change), |
||||||
}; |
}; |
||||||
|
|
||||||
const endpoint = serverApi.PatchAlbumEndpoint.replace(":id", id.toString()); |
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString()); |
||||||
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) |
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts) |
||||||
if(!response.ok) { |
if(!response.ok) { |
||||||
throw new Error("Failed to save album changes: " + response.statusText); |
throw new Error("Failed to save album changes: " + response.statusText); |
||||||
} |
} |
||||||
|
|||||||
@ -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,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