Add more filter controls, type filtering, refactoring

pull/7/head
Sander Vocke 5 years ago
parent 31196ffdf5
commit b282a002b8
  1. 14
      README
  2. 204
      client/src/App.tsx
  3. 94
      client/src/api.ts
  4. 91
      client/src/components/BrowseWindow.tsx
  5. 30
      client/src/components/FilterControl.tsx
  6. 120
      client/src/components/QueryBrowseWindow.tsx
  7. 32
      client/src/types/Query.tsx
  8. 5
      server/app.ts
  9. 145
      server/endpoints/QueryEndpointHandler.ts
  10. 98
      server/endpoints/QuerySongsEndpointHandler.ts

@ -1 +1,13 @@
Started from: https://www.freecodecamp.org/news/how-to-make-create-react-app-work-with-a-node-backend-api-7c5c48acb1b0/
Started from: https://www.freecodecamp.org/news/how-to-make-create-react-app-work-with-a-node-backend-api-7c5c48acb1b0/
TODO:
- Ranking system
- Have "ranking contexts". These can be stored in the database.
- Per artist (this removes need for "per album", which can be a subset)
- Per tag
- Per playlist
- Have a linking table between contexts <-> artists/songs. This linking table should include an optional ranking score.
- The ranking score allows ranking songs per query or per query element. It is a floating point so we can always insert stuff in between.
- Visually, the system shows ranked items in a table and unranked items in another. User can drag to rank.

@ -1,16 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Paper } from '@material-ui/core';
import StoreIcon from '@material-ui/icons/Store';
import * as serverApi from './api';
import AppBar, { ActiveTab as AppBarActiveTab } from './components/AppBar';
import ItemList from './components/ItemList';
import ItemListItem from './components/ItemListItem';
import FilterControl from './components/FilterControl';
import { SongQuery, toApiQuery, isSongQuery, QueryKeys } from './types/Query';
import { SongDisplayItem, ArtistDisplayItem } from './types/DisplayItem';
import { ReactComponent as GooglePlayIcon } from './assets/googleplaymusic_icon.svg';
import { Query, isQuery, QueryKeys } from './types/Query';
import QueryBrowseWindow, { TypesIncluded } from './components/QueryBrowseWindow';
import {
BrowserRouter as Router,
@ -22,118 +14,15 @@ import {
} from "react-router-dom";
const JSURL = require('jsurl');
const _ = require('lodash');
interface SongItemProps {
song: serverApi.SongDetails,
}
interface ArtistItemProps {
id: Number,
}
const getStoreIcon = (url: String) => {
if (url.includes('play.google.com')) {
return <GooglePlayIcon height='30px' width='30px' />;
}
return <StoreIcon />;
}
function SongItem(props: SongItemProps) {
const displayItem: SongDisplayItem = {
title: props.song.title,
artistNames: props.song.artists && props.song.artists.map((artist: serverApi.ArtistDetails) => {
return artist.name;
}) || ['Unknown'],
tagNames: props.song.tags && props.song.tags.map((tag: serverApi.TagDetails) => {
return tag.name;
}) || [],
storeLinks: props.song.storeLinks && props.song.storeLinks.map((url: String) => {
return {
icon: getStoreIcon(url),
url: url
}
}) || [],
}
return <ItemListItem item={displayItem} />;
}
interface SongListProps {
songs: serverApi.SongDetails[]
}
function SongList(props: SongListProps) {
return <Paper>
<ItemList>
{props.songs.map((song: any) => {
return <SongItem song={song} />;
})}
</ItemList>
</Paper>;
}
function ArtistItem(props: ArtistItemProps) {
const [artistDisplayItem, setArtistDisplayItem] = React.useState<ArtistDisplayItem | undefined>(undefined);
const updateArtist = async () => {
const response: any = await fetch(serverApi.ArtistDetailsEndpoint.replace(':id', props.id.toString()));
const json: any = await response.json();
const tagIds: Number[] | undefined = json.tagIds;
const tagNamesPromises: Promise<String>[] | undefined = tagIds && tagIds.map((id: Number) => {
return fetch(serverApi.TagDetailsEndpoint.replace(':id', id.toString()))
.then((response: any) => response.json())
.then((json: any) => json.name);
});
const tagNames: String[] | undefined = tagNamesPromises && await Promise.all(tagNamesPromises);
function fixQuery(q: any): Query {
if (!isQuery(q)) {
return {
name: json.name ? json.name : "Unknown",
tagNames: tagNames ? tagNames : [],
storeLinks: json.storeLinks.map((url: String) => {
return {
icon: getStoreIcon(url),
url: url
}
}),
};
};
useEffect(() => {
updateArtist().then((artist: ArtistDisplayItem) => { setArtistDisplayItem(artist); });
}, []);
return <ItemListItem item={artistDisplayItem ? artistDisplayItem : {
loadingArtist: true
}} />;
}
function ArtistList() {
const [artists, setArtists] = useState<Number[]>([]);
React.useEffect(() => {
const request: serverApi.QueryArtistsRequest = {
offset: 0,
limit: 20,
}
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
[QueryKeys.TitleLike]: ''
};
fetch(serverApi.QueryArtistsEndpoint, requestOpts)
.then((response: any) => response.json())
.then((json: any) => {
'ids' in json && setArtists(json.ids);
});
}, []);
return <Paper>
<ItemList>
{artists.map((song: any) => {
return <ArtistItem id={song} />;
})}
</ItemList>
</Paper>;
}
return q;
}
function AppBody() {
@ -141,65 +30,28 @@ function AppBody() {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const [ types, setTypes ] = useState<TypesIncluded>({songs: true, artists: true, tags: true});
// If we have an invalid query, change to the default one.
const songQuery: SongQuery | undefined = JSURL.tryParse(queryParams.get('query'), undefined);
const itemQuery: Query | undefined = JSURL.tryParse(queryParams.get('query'), undefined);
const [songs, setSongs] = useState<serverApi.SongDetails[]>([]);
const offset: number | undefined = queryParams.get('offset') ? parseInt(queryParams.get('offset') || '0') : undefined;
const limit: number | undefined = queryParams.get('limit') ? parseInt(queryParams.get('limit') || '0') : undefined;
const fixQueryParams = () => {
var fixed = false;
if (!isSongQuery(songQuery)) {
console.log("query");
queryParams.set('query', JSURL.stringify({
[QueryKeys.TitleLike]: ''
}));
fixed = true;
}
if (offset == undefined) {
console.log("offset", offset);
queryParams.set('offset', '0');
fixed = true;
}
if (limit == undefined) {
console.log("limit");
queryParams.set('limit', '20');
fixed = true;
}
console.log("fixed", fixed);
return fixed;
}
const pushQueryParams = () => {
const pushQuery = (q: Query) => {
const newParams = new URLSearchParams(); //TODO this throws away all other stuff
newParams.set('query', JSURL.stringify(q));
history.push({
search: "?" + queryParams.toString()
search: "?" + newParams.toString()
})
}
useEffect(() => {
if (fixQueryParams()) {
pushQueryParams();
const fq = fixQuery(itemQuery);
if (fq != itemQuery) {
pushQuery(fq);
return;
}
const query: SongQuery = songQuery || { [QueryKeys.TitleLike]: '' };
setSongs([]);
const request: serverApi.QuerySongsRequest = {
query: toApiQuery(query),
offset: offset || 0,
limit: limit || 0,
}
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
};
fetch(serverApi.QuerySongsEndpoint, requestOpts)
.then((response: any) => response.json())
.then((json: any) => {
'songs' in json && query === songQuery && setSongs(json.songs);
});
}, [location]);
const onAppBarTabChange = (value: AppBarActiveTab) => {
@ -211,24 +63,22 @@ function AppBody() {
}
}
const onQueryChange = (q: Query) => {
pushQuery(q);
}
return (
<div style={{ maxWidth: '100%' }}>
<Switch>
<Redirect exact from='/' to="/query" />
<Route path='/query'>
<AppBar activeTab={AppBarActiveTab.Query} onActiveTabChange={onAppBarTabChange} />
<FilterControl
query={songQuery}
onChangeQuery={(squery: SongQuery) => {
if (squery != songQuery) {
queryParams.set('query', JSURL.stringify(squery));
pushQueryParams();
}
}}
<QueryBrowseWindow
query={itemQuery}
typesIncluded={types}
onQueryChange={onQueryChange}
onTypesChange={setTypes}
/>
<Paper>
<SongList songs={songs} />
</Paper>
</Route>
</Switch>
</div>

@ -26,45 +26,51 @@ export interface SongDetails {
storeLinks?: String[],
}
// Query for songs (POST).
export const QuerySongsEndpoint = '/song/query';
export enum SongQueryElemOp {
// Query for items (POST).
export const QueryEndpoint = '/query';
export enum QueryElemOp {
And = "AND",
Or = "OR",
}
export enum SongQueryFilterOp {
export enum QueryFilterOp {
Eq = "EQ",
Ne = "NE",
In = "IN",
NotIn = "NOTIN",
Like = "LIKE",
}
export enum SongQueryElemProperty {
title = "title",
id = "id",
artistNames = "artistNames",
albumNames = "albumNames",
export enum QueryElemProperty {
songTitle = "songTitle",
songId = "songId",
artistName = "artistName",
albumName = "albumName",
}
export interface SongQueryElem {
prop?: SongQueryElemProperty,
export interface QueryElem {
prop?: QueryElemProperty,
propOperand?: any,
propOperator?: SongQueryFilterOp,
children?: SongQueryElem[]
childrenOperator?: SongQueryElemOp,
}
export interface SongQuery extends SongQueryElem { }
export interface QuerySongsRequest {
query: SongQuery,
offset: number,
limit: number,
}
export interface QuerySongsResponse {
songs: SongDetails[]
}
export function checkQuerySongsElem(elem: any): boolean {
propOperator?: QueryFilterOp,
children?: QueryElem[]
childrenOperator?: QueryElemOp,
}
export interface Query extends QueryElem { }
export interface QueryRequest {
query: Query,
songOffset: number,
songLimit: number,
artistOffset: number,
artistLimit: number,
tagOffset: number,
tagLimit: number,
}
export interface QueryResponse {
songs: SongDetails[],
artists: ArtistDetails[],
tags: TagDetails[],
}
export function checkQueryElem(elem: any): boolean {
if (elem.childrenOperator && elem.children) {
elem.children.forEach((child: any) => {
if (!checkQuerySongsElem(child)) {
if (!checkQueryElem(child)) {
return false;
}
});
@ -73,11 +79,15 @@ export function checkQuerySongsElem(elem: any): boolean {
(elem.prop && elem.propOperand && elem.propOperator) ||
Object.keys(elem).length == 0;
}
export function checkQuerySongsRequest(req: any): boolean {
export function checkQueryRequest(req: any): boolean {
return 'query' in req
&& 'offset' in req
&& 'limit' in req
&& checkQuerySongsElem(req.query);
&& 'songOffset' in req
&& 'songLimit' in req
&& 'artistOffset' in req
&& 'artistLimit' in req
&& 'tagOffset' in req
&& 'tagLimit' in req
&& checkQueryElem(req.query);
}
// Get song details (GET).
@ -94,20 +104,6 @@ export function checkSongDetailsRequest(req: any): boolean {
return true;
}
// Query for artists.
export const QueryArtistsEndpoint = '/artist/query';
export interface QueryArtistsRequest {
offset: Number,
limit: Number,
}
export interface QueryArtistsResponse {
ids: Number[]
}
export function checkQueryArtistsRequest(req: any): boolean {
return 'offset' in req
&& 'limit' in req;
}
// Get artist details (GET).
export const ArtistDetailsEndpoint = '/artist/:id';
export interface ArtistDetailsRequest { }
@ -203,16 +199,6 @@ export function checkModifyTagRequest(req: any): boolean {
return true;
}
// Query for tags.
export const QueryTagEndpoint = '/tag/query';
export interface QueryTagsRequest { }
export interface QueryTagsResponse {
ids: Number[]
}
export function checkQueryTagsRequest(req: any): boolean {
return true;
}
// Get tag details (GET).
export const TagDetailsEndpoint = '/tag/:id';
export interface TagDetailsRequest { }

@ -0,0 +1,91 @@
import React from 'react';
import { Paper } from '@material-ui/core';
import { DisplayItem } from '../types/DisplayItem';
import ItemListItem from './ItemListItem';
import ItemList from './ItemList';
import * as serverApi from '../api';
import StoreIcon from '@material-ui/icons/Store';
import { ReactComponent as GooglePlayIcon } from '../assets/googleplaymusic_icon.svg';
export interface SongItem extends serverApi.SongDetails {
songSignature: any
}
export function isSongItem(q: any): q is SongItem {
return 'songSignature' in q;
}
export function toSongItem(i: serverApi.SongDetails) {
const r: any = i;
r['songSignature'] = true;
return r;
}
export interface ArtistItem extends serverApi.ArtistDetails {
artistSignature: any
}
export function isArtistItem(q: any): q is ArtistItem {
return 'artistSignature' in q;
}
export function toArtistItem(i: serverApi.ArtistDetails) {
const r: any = i;
r['artistSignature'] = true;
return r;
}
export type Item = SongItem | ArtistItem;
const getStoreIcon = (url: String) => {
if (url.includes('play.google.com')) {
return <GooglePlayIcon height='30px' width='30px' />;
}
return <StoreIcon />;
}
function toDisplayItem(item: Item): DisplayItem | undefined {
if (isSongItem(item)) {
return {
title: item.title,
artistNames: item.artists && item.artists.map((artist: serverApi.ArtistDetails) => {
return artist.name;
}) || ['Unknown'],
tagNames: item.tags && item.tags.map((tag: serverApi.TagDetails) => {
return tag.name;
}) || [],
storeLinks: item.storeLinks && item.storeLinks.map((url: String) => {
return {
icon: getStoreIcon(url),
url: url
}
}) || [],
}
} else if (isArtistItem(item)) {
return {
name: item.name ? item.name : "Unknown",
tagNames: [], // TODO
storeLinks: item.storeLinks && item.storeLinks.map((url: String) => {
return {
icon: getStoreIcon(url),
url: url
}
}) || [],
};
}
return undefined;
}
interface IProps {
items: Item[]
}
export default function BrowseWindow(props: IProps) {
return <Paper>
<ItemList>
{props.items.map((item: Item) => {
const di = toDisplayItem(item);
return di && <ItemListItem item={di} />;
})}
</ItemList>
</Paper>;
}

@ -13,7 +13,7 @@ import {
ArtistQuery,
isTitleQuery,
isArtistQuery,
SongQuery,
Query,
isAndQuery,
isOrQuery,
QueryKeys,
@ -21,7 +21,7 @@ import {
interface TitleFilterControlProps {
query: TitleQuery,
onChangeQuery: (q: SongQuery) => void,
onChangeQuery: (q: Query) => void,
}
function TitleFilterControl(props: TitleFilterControlProps) {
return <TextField
@ -35,7 +35,7 @@ function TitleFilterControl(props: TitleFilterControlProps) {
interface ArtistFilterControlProps {
query: ArtistQuery,
onChangeQuery: (q: SongQuery) => void,
onChangeQuery: (q: Query) => void,
}
function ArtistFilterControl(props: ArtistFilterControlProps) {
return <TextField
@ -49,10 +49,10 @@ function ArtistFilterControl(props: ArtistFilterControlProps) {
interface AndNodeControlProps {
query: any,
onChangeQuery: (q: SongQuery) => void,
onChangeQuery: (q: Query) => void,
}
function AndNodeControl(props: AndNodeControlProps) {
const onChangeSubQuery = (a: SongQuery, b: SongQuery) => {
const onChangeSubQuery = (a: Query, b: Query) => {
props.onChangeQuery({
[QueryKeys.AndQuerySignature]: true,
[QueryKeys.OperandA]: a,
@ -63,18 +63,18 @@ function AndNodeControl(props: AndNodeControlProps) {
return <Paper>
{props.query && isAndQuery(props.query) && <>
<Typography>And</Typography>
<FilterControl query={props.query.a} onChangeQuery={(q: SongQuery) => { onChangeSubQuery(q, props.query.b); }} />
<FilterControl query={props.query.b} onChangeQuery={(q: SongQuery) => { onChangeSubQuery(props.query.a, q); }} />
<FilterControl query={props.query.a} onChangeQuery={(q: Query) => { onChangeSubQuery(q, props.query.b); }} />
<FilterControl query={props.query.b} onChangeQuery={(q: Query) => { onChangeSubQuery(props.query.a, q); }} />
</>}
</Paper>;
}
interface OrNodeControlProps {
query: any,
onChangeQuery: (q: SongQuery) => void,
onChangeQuery: (q: Query) => void,
}
function OrNodeControl(props: OrNodeControlProps) {
const onChangeSubQuery = (a: SongQuery, b: SongQuery) => {
const onChangeSubQuery = (a: Query, b: Query) => {
props.onChangeQuery({
[QueryKeys.OrQuerySignature]: true,
[QueryKeys.OperandA]: a,
@ -85,15 +85,15 @@ function OrNodeControl(props: OrNodeControlProps) {
return <Paper>
{props.query && isOrQuery(props.query) && <>
<Typography>Or</Typography>
<FilterControl query={props.query.a} onChangeQuery={(q: SongQuery) => { onChangeSubQuery(q, props.query.b); }} />
<FilterControl query={props.query.b} onChangeQuery={(q: SongQuery) => { onChangeSubQuery(props.query.a, q); }} />
<FilterControl query={props.query.a} onChangeQuery={(q: Query) => { onChangeSubQuery(q, props.query.b); }} />
<FilterControl query={props.query.b} onChangeQuery={(q: Query) => { onChangeSubQuery(props.query.a, q); }} />
</>}
</Paper>;
}
export interface IProps {
query: SongQuery | undefined,
onChangeQuery: (query: SongQuery) => void,
query: Query | undefined,
onChangeQuery: (query: Query) => void,
}
export function FilterControlLeaf(props: IProps) {
@ -182,10 +182,10 @@ export function FilterControlNode(props: IProps) {
}
export default function FilterControl(props: IProps) {
const isLeaf = (query: SongQuery | undefined) => {
const isLeaf = (query: Query | undefined) => {
return query && (isTitleQuery(query) || isArtistQuery(query));
}
const isNode = (query: SongQuery | undefined) => !isLeaf(query);
const isNode = (query: Query | undefined) => !isLeaf(query);
return <>
{isLeaf(props.query) && <FilterControlLeaf {...props} />}

@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { Query, toApiQuery } from '../types/Query';
import FilterControl from './FilterControl';
import * as serverApi from '../api';
import BrowseWindow, { toSongItem, toArtistItem, Item } from './BrowseWindow';
import { FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox } from '@material-ui/core';
const _ = require('lodash');
export interface TypesIncluded {
songs: boolean,
artists: boolean,
tags: boolean,
}
interface ItemTypeCheckboxesProps {
types: TypesIncluded,
onChange: (types: TypesIncluded) => void;
}
function ItemTypeCheckboxes(props: ItemTypeCheckboxesProps) {
const songChange = (v: any) => {
props.onChange({
songs: v.target.checked,
artists: props.types.artists,
tags: props.types.tags
});
}
const artistChange = (v: any) => {
props.onChange({
songs: props.types.songs,
artists: v.target.checked,
tags: props.types.tags
});
}
const tagChange = (v: any) => {
props.onChange({
songs: props.types.songs,
artists: props.types.artists,
tags: v.target.checked
});
}
return <FormControl component='fieldset'>
<FormLabel component='legend'>Result types</FormLabel>
<FormGroup>
<FormControlLabel
control={<Checkbox checked={props.types.songs} onChange={songChange} name='Songs' />}
label="Songs"
/>
<FormControlLabel
control={<Checkbox checked={props.types.artists} onChange={artistChange} name='Artists' />}
label="Artists"
/>
<FormControlLabel
control={<Checkbox checked={props.types.tags} onChange={tagChange} name='Tags' />}
label="Tags"
/>
</FormGroup>
</FormControl>;
}
export interface IProps {
query: Query | undefined,
typesIncluded: TypesIncluded,
onQueryChange: (q: Query) => void,
onTypesChange: (t: TypesIncluded) => void,
}
export default function QueryBrowseWindow(props: IProps) {
const [songs, setSongs] = useState<serverApi.SongDetails[]>([]);
const [artists, setArtists] = useState<serverApi.ArtistDetails[]>([]);
//const [tags, setTags] = useState<serverApi.TagDetails[]>([]);
const songItems: Item[] = songs.map(toSongItem);
const artistItems: Item[] = artists.map(toArtistItem);
var items: Item[] = [];
props.typesIncluded.songs && items.push(...songItems);
props.typesIncluded.artists && items.push(...artistItems);
useEffect(() => {
if (!props.query) { return; }
const q = _.cloneDeep(props.query);
const request: serverApi.QueryRequest = {
query: toApiQuery(props.query),
songOffset: 0,
songLimit: 5, // TODO
artistOffset: 0,
artistLimit: 5,
tagOffset: 0,
tagLimit: 5,
}
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
};
fetch(serverApi.QueryEndpoint, requestOpts)
.then((response: any) => response.json())
.then((json: any) => {
'songs' in json && _.isEqual(q, props.query) && setSongs(json.songs);
'artists' in json && _.isEqual(q, props.query) && setArtists(json.artists);
});
}, [props.query]);
return <>
<FilterControl
query={props.query}
onChangeQuery={props.onQueryChange}
/>
<ItemTypeCheckboxes
types={props.typesIncluded}
onChange={props.onTypesChange}
/>
<BrowseWindow items={items} />
</>
}

@ -1,4 +1,4 @@
import { SongQueryElemProperty, SongQueryFilterOp, SongQueryElemOp } from '../api';
import { QueryElemProperty, QueryFilterOp, QueryElemOp } from '../api';
export enum QueryKeys {
TitleLike = 'tl',
@ -12,28 +12,28 @@ export enum QueryKeys {
export interface TitleQuery {
[QueryKeys.TitleLike]: String
};
export function isTitleQuery(q: SongQuery): q is TitleQuery {
export function isTitleQuery(q: Query): q is TitleQuery {
return QueryKeys.TitleLike in q;
}
export function TitleToApiQuery(q: TitleQuery) {
return {
'prop': SongQueryElemProperty.title,
'prop': QueryElemProperty.songTitle,
'propOperand': '%' + q[QueryKeys.TitleLike] + '%',
'propOperator': SongQueryFilterOp.Like,
'propOperator': QueryFilterOp.Like,
}
}
export interface ArtistQuery {
[QueryKeys.ArtistLike]: String
};
export function isArtistQuery(q: SongQuery): q is ArtistQuery {
export function isArtistQuery(q: Query): q is ArtistQuery {
return QueryKeys.ArtistLike in q;
}
export function ArtistToApiQuery(q: ArtistQuery) {
return {
'prop': SongQueryElemProperty.artistNames,
'prop': QueryElemProperty.artistName,
'propOperand': '%' + q[QueryKeys.ArtistLike] + '%',
'propOperator': SongQueryFilterOp.Like,
'propOperator': QueryFilterOp.Like,
}
}
@ -42,12 +42,12 @@ export interface AndQuery<T> {
[QueryKeys.OperandA]: T,
[QueryKeys.OperandB]: T,
}
export function isAndQuery(q: SongQuery): q is AndQuery<SongQuery> {
export function isAndQuery(q: Query): q is AndQuery<Query> {
return QueryKeys.AndQuerySignature in q;
}
export function AndToApiQuery(q: AndQuery<SongQuery>) {
export function AndToApiQuery(q: AndQuery<Query>) {
return {
'childrenOperator': SongQueryElemOp.And,
'childrenOperator': QueryElemOp.And,
'children': [
toApiQuery(q.a),
toApiQuery(q.b),
@ -60,12 +60,12 @@ export interface OrQuery<T> {
[QueryKeys.OperandA]: T,
[QueryKeys.OperandB]: T,
}
export function isOrQuery(q: SongQuery): q is OrQuery<SongQuery> {
export function isOrQuery(q: Query): q is OrQuery<Query> {
return QueryKeys.OrQuerySignature in q;
}
export function OrToApiQuery(q: OrQuery<SongQuery>) {
export function OrToApiQuery(q: OrQuery<Query>) {
return {
'childrenOperator': SongQueryElemOp.Or,
'childrenOperator': QueryElemOp.Or,
'children': [
toApiQuery(q.a),
toApiQuery(q.b),
@ -73,14 +73,14 @@ export function OrToApiQuery(q: OrQuery<SongQuery>) {
}
}
export type SongQuery = TitleQuery | ArtistQuery | AndQuery<SongQuery> | OrQuery<SongQuery>;
export type Query = TitleQuery | ArtistQuery | AndQuery<Query> | OrQuery<Query>;
export function isSongQuery(q: any): q is SongQuery {
export function isQuery(q: any): q is Query {
return q != null &&
(isTitleQuery(q) || isArtistQuery(q) || isAndQuery(q) || isOrQuery(q));
}
export function toApiQuery(q: SongQuery): any {
export function toApiQuery(q: Query): any {
return (isTitleQuery(q) && TitleToApiQuery(q)) ||
(isArtistQuery(q) && ArtistToApiQuery(q)) ||
(isAndQuery(q) && AndToApiQuery(q)) ||

@ -3,7 +3,7 @@ import * as api from '../client/src/api';
import { CreateSongEndpointHandler } from './endpoints/CreateSongEndpointHandler';
import { CreateArtistEndpointHandler } from './endpoints/CreateArtistEndpointHandler';
import { QuerySongsEndpointHandler } from './endpoints/QuerySongsEndpointHandler';
import { QueryEndpointHandler } from './endpoints/QueryEndpointHandler';
import { QueryArtistsEndpointHandler } from './endpoints/QueryArtistsEndpointHandler';
import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetailsEndpointHandler'
import { SongDetailsEndpointHandler } from './endpoints/SongDetailsEndpointHandler';
@ -34,9 +34,8 @@ const SetupApp = (app: any) => {
// Set up REST API endpoints
app.post(api.CreateSongEndpoint, invokeHandler(CreateSongEndpointHandler));
app.post(api.QuerySongsEndpoint, invokeHandler(QuerySongsEndpointHandler));
app.post(api.QueryEndpoint, invokeHandler(QueryEndpointHandler));
app.post(api.CreateArtistEndpoint, invokeHandler(CreateArtistEndpointHandler));
app.post(api.QueryArtistsEndpoint, invokeHandler(QueryArtistsEndpointHandler));
app.put(api.ModifyArtistEndpoint, invokeHandler(ModifyArtistEndpointHandler));
app.put(api.ModifySongEndpoint, invokeHandler(ModifySongEndpointHandler));
app.get(api.SongDetailsEndpoint, invokeHandler(SongDetailsEndpointHandler));

@ -0,0 +1,145 @@
const models = require('../models');
const { Op } = require("sequelize");
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
enum QueryType {
Song = 0,
Artist,
Tag,
}
const sequelizeOps: any = {
[api.QueryFilterOp.Eq]: Op.eq,
[api.QueryFilterOp.Ne]: Op.ne,
[api.QueryFilterOp.In]: Op.in,
[api.QueryFilterOp.NotIn]: Op.notIn,
[api.QueryFilterOp.Like]: Op.like,
[api.QueryElemOp.And]: Op.and,
[api.QueryElemOp.Or]: Op.or,
};
const sequelizeProps: any = {
[QueryType.Song]: {
[api.QueryElemProperty.songTitle]: "title",
[api.QueryElemProperty.songId]: "id",
[api.QueryElemProperty.artistName]: "$Artists.name$",
[api.QueryElemProperty.albumName]: "$Albums.name$",
},
[QueryType.Artist]: {
[api.QueryElemProperty.songTitle]: "$Songs.title$",
[api.QueryElemProperty.songId]: "$Songs.id$",
[api.QueryElemProperty.artistName]: "name",
[api.QueryElemProperty.albumName]: "$Albums.name$",
},
[QueryType.Tag]: {
[api.QueryElemProperty.songTitle]: "$Songs.title$",
[api.QueryElemProperty.songId]: "$Songs.id$",
[api.QueryElemProperty.artistName]: "$Artists.name$",
[api.QueryElemProperty.albumName]: "$Albums.name$",
}
};
// Returns the "where" clauses for Sequelize, per object type.
const getSequelizeWhere = (queryElem: api.QueryElem, type: QueryType) => {
var where: any = {
[Op.and]: []
};
if (queryElem.prop && queryElem.propOperator && queryElem.propOperand) {
// Visit a filter-like subquery leaf.
where[Op.and].push({
[sequelizeProps[type][queryElem.prop]]: {
[sequelizeOps[queryElem.propOperator]]: queryElem.propOperand
}
});
}
if (queryElem.childrenOperator && queryElem.children) {
// Recursively visit a nested subquery.
const children = queryElem.children.map((child: api.QueryElem) => getSequelizeWhere(child, type));
where[Op.and].push({
[sequelizeOps[queryElem.childrenOperator]]: children
});
}
return where;
}
export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any) => {
if (!api.checkQueryRequest(req.body)) {
const e: EndpointError = {
internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.QueryRequest = req.body;
try {
const songs = (reqObject.songLimit > 0) && await models.Song.findAll({
// NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938.
// Custom pagination is implemented before responding.
where: getSequelizeWhere(reqObject.query, QueryType.Song),
include: [models.Artist, models.Album, models.Tag],
//limit: reqObject.limit,
//offset: reqObject.offset,
})
const artists = (reqObject.artistLimit > 0) && await models.Artist.findAll({
// NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938.
// Custom pagination is implemented before responding.
where: getSequelizeWhere(reqObject.query, QueryType.Artist),
include: [models.Song, models.Album, models.Tag],
//limit: reqObject.limit,
//offset: reqObject.offset,
})
const tags = (reqObject.tagLimit > 0) && await models.Tag.findAll({
// NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938.
// Custom pagination is implemented before responding.
where: getSequelizeWhere(reqObject.query, QueryType.Tag),
include: [models.Song, models.Album, models.Artist],
//limit: reqObject.limit,
//offset: reqObject.offset,
})
const response: api.QueryResponse = {
songs: (reqObject.songLimit <= 0) ? [] : await Promise.all(songs.map(async (song: any) => {
const artists = await song.getArtists();
const tags = await song.getTags();
return <api.SongDetails>{
id: song.id,
title: song.title,
storeLinks: song.storeLinks,
artists: artists.map((artist: any) => {
return <api.ArtistDetails>{
id: artist.id,
name: artist.name,
}
}),
tags: tags.map((tag: any) => {
return <api.TagDetails>{
id: tag.id,
name: tag.name,
}
}),
};
}).slice(reqObject.songOffset, reqObject.songOffset + reqObject.songLimit)),
// TODO: custom pagination due to bug mentioned above
artists: (reqObject.artistLimit <= 0) ? [] : await Promise.all(artists.map(async (artist: any) => {
return <api.ArtistDetails>{
id: artist.id,
name: artist.name,
};
}).slice(reqObject.artistOffset, reqObject.artistOffset + reqObject.artistLimit)),
tags: (reqObject.tagLimit <= 0) ? [] : await Promise.all(tags.map(async (tag: any) => {
return <api.TagDetails>{
id: tag.id,
name: tag.name,
};
}).slice(reqObject.tagOffset, reqObject.tagOffset + reqObject.tagLimit)),
};
res.send(response);
} catch (e) {
catchUnhandledErrors(e);
}
}

@ -1,98 +0,0 @@
const models = require('../models');
const { Op } = require("sequelize");
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
const sequelizeOps: any = {
[api.SongQueryFilterOp.Eq]: Op.eq,
[api.SongQueryFilterOp.Ne]: Op.ne,
[api.SongQueryFilterOp.In]: Op.in,
[api.SongQueryFilterOp.NotIn]: Op.notIn,
[api.SongQueryFilterOp.Like]: Op.like,
[api.SongQueryElemOp.And]: Op.and,
[api.SongQueryElemOp.Or]: Op.or,
};
const sequelizeProps: any = {
[api.SongQueryElemProperty.title]: "title",
[api.SongQueryElemProperty.id]: "id",
[api.SongQueryElemProperty.artistNames]: "$Artists.name$",
[api.SongQueryElemProperty.albumNames]: "$Albums.name$",
};
// Returns the "where" clauses for Sequelize, per object type.
const getSequelizeWhere = (queryElem: api.SongQueryElem) => {
var where: any = {
[Op.and]: []
};
if (queryElem.prop && queryElem.propOperator && queryElem.propOperand) {
// Visit a filter-like subquery leaf.
where[Op.and].push({
[sequelizeProps[queryElem.prop]]: {
[sequelizeOps[queryElem.propOperator]]: queryElem.propOperand
}
});
}
if (queryElem.childrenOperator && queryElem.children) {
// Recursively visit a nested subquery.
const children = queryElem.children.map((child: api.SongQueryElem) => getSequelizeWhere(child));
where[Op.and].push({
[sequelizeOps[queryElem.childrenOperator]]: children
});
}
return where;
}
export const QuerySongsEndpointHandler: EndpointHandler = async (req: any, res: any) => {
if (!api.checkQuerySongsRequest(req.body)) {
const e: EndpointError = {
internalMessage: 'Invalid QuerySongs request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.QuerySongsRequest = req.body;
try {
console.log('Song query:', reqObject.query, "where: ", getSequelizeWhere(reqObject.query))
const songs = await models.Song.findAll({
// NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938.
// Custom pagination is implemented before responding.
where: getSequelizeWhere(reqObject.query),
include: [models.Artist, models.Album, models.Tag],
//limit: reqObject.limit,
//offset: reqObject.offset,
})
const response: api.QuerySongsResponse = {
songs: await Promise.all(songs.map(async (song: any) => {
const artists = await song.getArtists();
const tags = await song.getTags();
return <api.SongDetails>{
id: song.id,
title: song.title,
storeLinks: song.storeLinks,
artists: artists.map((artist: any) => {
return <api.ArtistDetails>{
id: artist.id,
name: artist.name,
}
}),
tags: tags.map((tag: any) => {
return <api.TagDetails>{
id: tag.id,
name: tag.name,
}
}),
};
}).slice(reqObject.offset, reqObject.offset + reqObject.limit))
// TODO: custom pagination due to bug mentioned above
};
res.send(response);
} catch (e) {
catchUnhandledErrors(e);
}
}
Loading…
Cancel
Save