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