diff --git a/README b/README
index dc06ed5..6fdc8c7 100644
--- a/README
+++ b/README
@@ -1 +1,13 @@
-Started from: https://www.freecodecamp.org/news/how-to-make-create-react-app-work-with-a-node-backend-api-7c5c48acb1b0/
\ No newline at end of file
+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.
\ No newline at end of file
diff --git a/client/src/App.tsx b/client/src/App.tsx
index b8dde12..bc5068b 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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 ;
- }
- return ;
-}
-
-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 ;
-}
-
-interface SongListProps {
- songs: serverApi.SongDetails[]
-}
-function SongList(props: SongListProps) {
- return
-
- {props.songs.map((song: any) => {
- return ;
- })}
-
- ;
-}
-
-function ArtistItem(props: ArtistItemProps) {
- const [artistDisplayItem, setArtistDisplayItem] = React.useState(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[] | 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 ;
-}
-
-function ArtistList() {
- const [artists, setArtists] = useState([]);
-
- 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
-
- {artists.map((song: any) => {
- return ;
- })}
-
- ;
+ }
+ return q;
}
function AppBody() {
@@ -141,65 +30,28 @@ function AppBody() {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
+ const [ types, setTypes ] = useState({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([]);
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 (
- {
- if (squery != songQuery) {
- queryParams.set('query', JSURL.stringify(squery));
- pushQueryParams();
- }
- }}
+
-
-
-
diff --git a/client/src/api.ts b/client/src/api.ts
index 6ff2174..3ec1933 100644
--- a/client/src/api.ts
+++ b/client/src/api.ts
@@ -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 { }
diff --git a/client/src/components/BrowseWindow.tsx b/client/src/components/BrowseWindow.tsx
new file mode 100644
index 0000000..277e0ef
--- /dev/null
+++ b/client/src/components/BrowseWindow.tsx
@@ -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 ;
+ }
+ return ;
+}
+
+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
+
+ {props.items.map((item: Item) => {
+ const di = toDisplayItem(item);
+ return di && ;
+ })}
+
+ ;
+}
\ No newline at end of file
diff --git a/client/src/components/FilterControl.tsx b/client/src/components/FilterControl.tsx
index 107c894..5004563 100644
--- a/client/src/components/FilterControl.tsx
+++ b/client/src/components/FilterControl.tsx
@@ -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 void,
+ onChangeQuery: (q: Query) => void,
}
function ArtistFilterControl(props: ArtistFilterControlProps) {
return 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
{props.query && isAndQuery(props.query) && <>
And
- { onChangeSubQuery(q, props.query.b); }} />
- { onChangeSubQuery(props.query.a, q); }} />
+ { onChangeSubQuery(q, props.query.b); }} />
+ { onChangeSubQuery(props.query.a, q); }} />
>}
;
}
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
{props.query && isOrQuery(props.query) && <>
Or
- { onChangeSubQuery(q, props.query.b); }} />
- { onChangeSubQuery(props.query.a, q); }} />
+ { onChangeSubQuery(q, props.query.b); }} />
+ { onChangeSubQuery(props.query.a, q); }} />
>}
;
}
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) && }
diff --git a/client/src/components/QueryBrowseWindow.tsx b/client/src/components/QueryBrowseWindow.tsx
new file mode 100644
index 0000000..e25b48a
--- /dev/null
+++ b/client/src/components/QueryBrowseWindow.tsx
@@ -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
+ Result types
+
+ }
+ label="Songs"
+ />
+ }
+ label="Artists"
+ />
+ }
+ label="Tags"
+ />
+
+ ;
+}
+
+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([]);
+ const [artists, setArtists] = useState([]);
+ //const [tags, setTags] = useState([]);
+
+ 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 <>
+
+
+
+ >
+}
diff --git a/client/src/types/Query.tsx b/client/src/types/Query.tsx
index 7ddb1dd..e58c9c7 100644
--- a/client/src/types/Query.tsx
+++ b/client/src/types/Query.tsx
@@ -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 {
[QueryKeys.OperandA]: T,
[QueryKeys.OperandB]: T,
}
-export function isAndQuery(q: SongQuery): q is AndQuery {
+export function isAndQuery(q: Query): q is AndQuery {
return QueryKeys.AndQuerySignature in q;
}
-export function AndToApiQuery(q: AndQuery) {
+export function AndToApiQuery(q: AndQuery) {
return {
- 'childrenOperator': SongQueryElemOp.And,
+ 'childrenOperator': QueryElemOp.And,
'children': [
toApiQuery(q.a),
toApiQuery(q.b),
@@ -60,12 +60,12 @@ export interface OrQuery {
[QueryKeys.OperandA]: T,
[QueryKeys.OperandB]: T,
}
-export function isOrQuery(q: SongQuery): q is OrQuery {
+export function isOrQuery(q: Query): q is OrQuery {
return QueryKeys.OrQuerySignature in q;
}
-export function OrToApiQuery(q: OrQuery) {
+export function OrToApiQuery(q: OrQuery) {
return {
- 'childrenOperator': SongQueryElemOp.Or,
+ 'childrenOperator': QueryElemOp.Or,
'children': [
toApiQuery(q.a),
toApiQuery(q.b),
@@ -73,14 +73,14 @@ export function OrToApiQuery(q: OrQuery) {
}
}
-export type SongQuery = TitleQuery | ArtistQuery | AndQuery | OrQuery;
+export type Query = TitleQuery | ArtistQuery | AndQuery | OrQuery;
-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)) ||
diff --git a/server/app.ts b/server/app.ts
index eaa24e9..101981f 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -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));
diff --git a/server/endpoints/QueryEndpointHandler.ts b/server/endpoints/QueryEndpointHandler.ts
new file mode 100644
index 0000000..f293845
--- /dev/null
+++ b/server/endpoints/QueryEndpointHandler.ts
@@ -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 {
+ id: song.id,
+ title: song.title,
+ storeLinks: song.storeLinks,
+ artists: artists.map((artist: any) => {
+ return {
+ id: artist.id,
+ name: artist.name,
+ }
+ }),
+ tags: tags.map((tag: any) => {
+ return {
+ 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 {
+ 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 {
+ id: tag.id,
+ name: tag.name,
+ };
+ }).slice(reqObject.tagOffset, reqObject.tagOffset + reqObject.tagLimit)),
+ };
+ res.send(response);
+ } catch (e) {
+ catchUnhandledErrors(e);
+ }
+}
\ No newline at end of file
diff --git a/server/endpoints/QuerySongsEndpointHandler.ts b/server/endpoints/QuerySongsEndpointHandler.ts
deleted file mode 100644
index f9e2b6b..0000000
--- a/server/endpoints/QuerySongsEndpointHandler.ts
+++ /dev/null
@@ -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 {
- id: song.id,
- title: song.title,
- storeLinks: song.storeLinks,
- artists: artists.map((artist: any) => {
- return {
- id: artist.id,
- name: artist.name,
- }
- }),
- tags: tags.map((tag: any) => {
- return {
- 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);
- }
-}
\ No newline at end of file