Instant song query.

pull/7/head
Sander Vocke 5 years ago
parent ffb82d6784
commit 7a137d7012
  1. 117
      client/src/App.tsx
  2. 39
      client/src/api.ts
  3. 88
      client/src/components/FilterControl.tsx
  4. 4
      client/src/components/ItemListLoadedArtistItem.tsx
  5. 4
      client/src/components/ItemListLoadedSongItem.tsx
  6. 2
      client/src/types/DisplayItem.tsx
  7. 32
      client/src/types/Query.tsx
  8. 13
      scripts/gpm_retrieve/gpm_retrieve.py
  9. 24
      server/endpoints/CreateArtistEndpointHandler.ts
  10. 9
      server/endpoints/QueryArtistsEndpointHandler.ts
  11. 40
      server/endpoints/QuerySongsEndpointHandler.ts

@ -7,6 +7,8 @@ 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 } from './types/Query';
import { SongDisplayItem, ArtistDisplayItem } from './types/DisplayItem';
import { ReactComponent as GooglePlayIcon } from './assets/googleplaymusic_icon.svg';
@ -17,9 +19,10 @@ import {
useHistory,
Redirect
} from "react-router-dom";
import { timeLog } from 'console';
interface SongItemProps {
id: Number,
song: serverApi.SongDetails,
}
interface ArtistItemProps {
@ -34,64 +37,35 @@ const getStoreIcon = (url: String) => {
}
function SongItem(props: SongItemProps) {
const [songDisplayItem, setSongDisplayItem] = React.useState<SongDisplayItem | undefined>(undefined);
const updateSong = async () => {
const response: any = await fetch(serverApi.SongDetailsEndpoint.replace(':id', props.id.toString()));
const json: any = await response.json();
const title: String | undefined = json.title;
const artistIds: Number[] | undefined = json.artistIds;
const artistNamesPromises: Promise<String>[] | undefined = artistIds && artistIds.map((id: Number) => {
return fetch(serverApi.ArtistDetailsEndpoint.replace(':id', id.toString()))
.then((response: any) => response.json())
.then((json: any) => json.name);
});
const artistNames: String[] | undefined = artistNamesPromises && await Promise.all(artistNamesPromises);
return {
title: title ? title : "Unknown",
artistNames: artistNames ? artistNames : [],
storeLinks: json.storeLinks.map((url: String) => {
return {
icon: getStoreIcon(url),
url: url
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: []
// json.storeLinks.map((url: String) => {
// return {
// icon: getStoreIcon(url),
// url: url
// }
// })
}
}),
};
};
useEffect(() => {
updateSong().then((song: SongDisplayItem) => { setSongDisplayItem(song); });
}, []);
return <ItemListItem item={songDisplayItem ? songDisplayItem : {
loadingSong: true
}} />;
return <ItemListItem item={displayItem} />;
}
function SongList() {
const [songs, setSongs] = useState<Number[]>([]);
React.useEffect(() => {
const request: serverApi.QuerySongsRequest = {
query: {}
interface SongListProps {
songs: serverApi.SongDetails[]
}
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) => {
'ids' in json && setSongs(json.ids);
});
}, []);
function SongList(props: SongListProps) {
return <Paper>
<ItemList>
{songs.map((song: any) => {
return <SongItem id={song} />;
{props.songs.map((song: any) => {
return <SongItem song={song} />;
})}
</ItemList>
</Paper>;
@ -103,9 +77,17 @@ function ArtistItem(props: ArtistItemProps) {
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);
return {
name: json.name ? json.name : "Unknown",
tagNames: tagNames ? tagNames : [],
storeLinks: json.storeLinks.map((url: String) => {
return {
icon: getStoreIcon(url),
@ -129,7 +111,8 @@ function ArtistList() {
React.useEffect(() => {
const request: serverApi.QueryArtistsRequest = {
query: {}
offset: 0,
limit: 20,
}
const requestOpts = {
method: 'POST',
@ -154,6 +137,30 @@ function ArtistList() {
function AppBody() {
const history = useHistory();
const [songQuery, setSongQuery] = useState<SongQuery>({
'titleLike': ''
});
const [songs, setSongs] = useState<serverApi.SongDetails[]>([]);
React.useEffect(() => {
const query = songQuery;
setSongs([]);
const request: serverApi.QuerySongsRequest = {
query: toApiQuery(query),
offset: 0,
limit: 20,
}
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);
});
}, [songQuery]);
const onAppBarTabChange = (value: AppBarActiveTab) => {
switch (value) {
@ -174,8 +181,12 @@ function AppBody() {
<Redirect exact from='/' to='/songs' />
<Route path='/songs'>
<AppBar activeTab={AppBarActiveTab.Songs} onActiveTabChange={onAppBarTabChange} />
<FilterControl
query={songQuery}
onChangeQuery={(query: SongQuery) => { setSongQuery(query); }}
/>
<Paper>
<SongList />
<SongList songs={songs} />
</Paper>
</Route>
<Route path='/artists'>

@ -7,6 +7,22 @@
// a request structure, a response structure and
// a checking function which determines request validity.
export interface ArtistDetails {
id: Number,
name: String,
}
export interface TagDetails {
id: Number,
name: String,
parent?: TagDetails,
}
export interface SongDetails {
id: Number,
title: String,
artists?: ArtistDetails[],
tags?: TagDetails[],
}
// Query for songs (POST).
export const QuerySongsEndpoint = '/song/query';
export enum SongQueryElemOp {
@ -17,9 +33,11 @@ export enum SongQueryFilterOp {
Eq = "EQ",
Ne = "NE",
In = "IN",
NotIn = "NOTIN"
NotIn = "NOTIN",
Like = "LIKE",
}
export enum SongQueryElemProperty {
title = "title",
id = "id",
artistIds = "artistIds",
albumIds = "albumIds",
@ -33,10 +51,12 @@ export interface SongQueryElem {
}
export interface SongQuery extends SongQueryElem { }
export interface QuerySongsRequest {
query: SongQuery
query: SongQuery,
offset: Number,
limit: Number,
}
export interface QuerySongsResponse {
ids: Number[]
songs: SongDetails[]
}
export function checkQuerySongsElem(elem: any): boolean {
if (elem.childrenOperator && elem.children) {
@ -51,7 +71,10 @@ export function checkQuerySongsElem(elem: any): boolean {
Object.keys(elem).length == 0;
}
export function checkQuerySongsRequest(req: any): boolean {
return "query" in req && checkQuerySongsElem(req.query);
return 'query' in req
&& 'offset' in req
&& 'limit' in req
&& checkQuerySongsElem(req.query);
}
// Get song details (GET).
@ -70,12 +93,16 @@ export function checkSongDetailsRequest(req: any): boolean {
// Query for artists.
export const QueryArtistsEndpoint = '/artist/query';
export interface QueryArtistsRequest { }
export interface QueryArtistsRequest {
offset: Number,
limit: Number,
}
export interface QueryArtistsResponse {
ids: Number[]
}
export function checkQueryArtistsRequest(req: any): boolean {
return true;
return 'offset' in req
&& 'limit' in req;
}
// Get artist details (GET).

@ -0,0 +1,88 @@
import React from 'react';
import {
TextField,
Paper,
Select,
MenuItem,
Typography
} from '@material-ui/core';
import {
TitleQuery,
ArtistQuery,
isTitleQuery,
isArtistQuery,
SongQuery
} from '../types/Query';
interface TitleFilterControlProps {
query: TitleQuery,
onChangeQuery: (q: SongQuery) => void,
}
function TitleFilterControl(props: TitleFilterControlProps) {
return <TextField
label="Title"
value={props.query.titleLike}
onChange={(i: any) => props.onChangeQuery({
titleLike: i.target.value
})}
/>
}
interface ArtistFilterControlProps {
query: ArtistQuery,
onChangeQuery: (q: SongQuery) => void,
}
function ArtistFilterControl(props: ArtistFilterControlProps) {
return <TextField
label="Name"
value={props.query.artistLike}
onChange={(i: any) => props.onChangeQuery({
artistLike: i.target.value
})}
/>
}
export interface IProps {
query: SongQuery,
onChangeQuery: (query: SongQuery) => void,
}
export default function FilterControl(props: IProps) {
const selectOptions: string[] = ['Title', 'Artist'];
const selectOption: string = (isTitleQuery(props.query) && 'Title') ||
(isArtistQuery(props.query) && 'Artist') ||
"Unknown";
const handleQueryOnChange = (event: any) => {
switch (event.target.value) {
case 'Title': {
props.onChangeQuery({
titleLike: ''
})
break;
}
case 'Artist': {
props.onChangeQuery({
artistLike: ''
})
break;
}
}
}
return <Paper>
<Select
value={selectOption}
onChange={handleQueryOnChange}
>
{selectOptions.map((option: string) => {
return <MenuItem value={option}>{option}</MenuItem>
})}
</Select>
{isTitleQuery(props.query) && <TitleFilterControl query={props.query} onChangeQuery={props.onChangeQuery} />}
{isArtistQuery(props.query) && <ArtistFilterControl query={props.query} onChangeQuery={props.onChangeQuery} />}
</Paper>;
}

@ -3,6 +3,7 @@ import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import GroupIcon from '@material-ui/icons/Group';
import Chip from '@material-ui/core/Chip';
import { ArtistDisplayItem } from '../types/DisplayItem';
@ -19,6 +20,9 @@ export default function ItemListLoadedArtistItem(props: IProps) {
<ListItemText
primary={props.item.name}
/>
{props.item.tagNames.map((tag: any) => {
return <Chip label={tag}/>
})}
{props.item.storeLinks.map((link: any) => {
return <a href={link.url} target="_blank">
<ListItemIcon>

@ -3,6 +3,7 @@ import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import MusicNoteIcon from '@material-ui/icons/MusicNote';
import Chip from '@material-ui/core/Chip';
import { SongDisplayItem } from '../types/DisplayItem';
@ -25,6 +26,9 @@ export default function ItemListLoadedSongItem(props: IProps) {
primary={props.item.title}
secondary={artists}
/>
{props.item.tagNames.map((tag: any) => {
return <Chip label={tag}/>
})}
{props.item.storeLinks.map((link: any) => {
return <a href={link.url} target="_blank">
<ListItemIcon>

@ -1,6 +1,7 @@
export interface SongDisplayItem {
title:String,
artistNames:String[],
tagNames:String[],
storeLinks: {
icon: JSX.Element,
url: String,
@ -13,6 +14,7 @@ export interface LoadingSongDisplayItem {
export interface ArtistDisplayItem {
name:String,
tagNames:String[],
storeLinks: {
icon: JSX.Element,
url: String,

@ -0,0 +1,32 @@
import { SongQueryElemProperty, SongQueryFilterOp } from '../api';
export interface TitleQuery {
titleLike: String
};
export function isTitleQuery(q: SongQuery): q is TitleQuery {
return "titleLike" in q;
}
export function TitleToApiQuery(q: TitleQuery) {
return {
'prop': SongQueryElemProperty.title,
'propOperand': '%' + q.titleLike + '%',
'propOperator': SongQueryFilterOp.Like,
}
}
export interface ArtistQuery {
artistLike: String
};
export function isArtistQuery(q: SongQuery): q is ArtistQuery {
return "artistLike" in q;
}
export function ArtistToApiQuery(q: ArtistQuery) {
return {
}
}
export type SongQuery = TitleQuery | ArtistQuery;
export function toApiQuery(q: SongQuery) {
return (isTitleQuery(q) && TitleToApiQuery(q)) ||
(isArtistQuery(q) && ArtistToApiQuery(q)) || {};
}

@ -35,6 +35,12 @@ def transferLibrary(gpm_api, mudbase_api):
return [];
artistStoreIds = [ [ getArtistStoreIds(song) for song in songs if song['artist'] == artist ][0] for artist in artists ]
# Create GPM import tag
gpmTagIdResponse = requests.post(mudbase_api + '/tag', data = {
'name': 'GPM Import'
}).json()
print(f"Created tag \"GPM Import\", response: {gpmTagIdResponse}")
# Create genres and store their mudbase Ids
genreRootResponse = requests.post(mudbase_api + '/tag', data = {
'name': 'Genre'
@ -52,9 +58,10 @@ def transferLibrary(gpm_api, mudbase_api):
# Create artists and store their mudbase Ids
artistMudbaseIds = []
for idx,artist in enumerate(artists):
response = requests.post(mudbase_api + '/artist', data = {
response = requests.post(mudbase_api + '/artist', json = {
'name': artist,
'storeLinks': [ 'https://play.google.com/music/m/' + id for id in artistStoreIds[idx] ]
'storeLinks': [ 'https://play.google.com/music/m/' + id for id in artistStoreIds[idx] ],
'tagIds': [ gpmTagIdResponse['id'] ]
}).json()
print(f"Created artist \"{artist}\", response: {response}")
artistMudbaseIds.append(response['id'])
@ -70,7 +77,7 @@ def transferLibrary(gpm_api, mudbase_api):
response = requests.post(mudbase_api + '/song', json = {
'title': song['title'],
'artistIds': [ artistMudbaseId ],
'tagIds' : [ genreMudbaseId ],
'tagIds' : [ genreMudbaseId, gpmTagIdResponse['id'] ],
'storeLinks': [ 'https://play.google.com/music/m/' + id for id in getSongStoreIds(song) ],
}).json()
print(f"Created song \"{song['title']}\" with artist ID {artistMudbaseId}, response: {response}")

@ -13,8 +13,12 @@ export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res
}
const reqObject: api.CreateArtistRequest = req.body;
console.log("Create artist:", reqObject)
try {
// Start retrieving the tag instances to link the artist to.
var tagInstancesPromise = reqObject.tagIds && models.Tag.findAll({
const tags = reqObject.tagIds && await models.Tag.findAll({
where: {
id: {
[Op.in]: reqObject.tagIds
@ -22,14 +26,11 @@ export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res
}
});
// Upon finish retrieving artists and albums, create the artist and associate it.
await Promise.all([tagInstancesPromise])
.then((values: any) => {
var [tags] = values;
console.log("Found artist tags:", tags)
if (reqObject.tagIds && tags.length !== reqObject.tagIds.length) {
const e: EndpointError = {
internalMessage: 'Not all atags exist for CreateArtist request: ' + JSON.stringify(req.body),
internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
@ -40,13 +41,12 @@ export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res
storeLinks: reqObject.storeLinks || [],
});
tags && artist.addTags(tags);
return artist.save();
})
.then((artist: any) => {
await artist.save();
const responseObject: api.CreateSongResponse = {
id: artist.id
};
res.status(200).send(responseObject);
})
.catch(catchUnhandledErrors);
await res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
}
}

@ -3,14 +3,19 @@ import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
export const QueryArtistsEndpointHandler: EndpointHandler = async (req: any, res: any) => {
if (!api.checkQueryArtistsRequest(req)) {
if (!api.checkQueryArtistsRequest(req.body)) {
const e: EndpointError = {
internalMessage: 'Invalid QueryArtists request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
await models.Artist.findAll()
const reqObject: api.QueryArtistsRequest = req.body;
await models.Artist.findAll({
offset: reqObject.offset,
limit: reqObject.limit,
})
.then((artists: any[]) => {
const response: api.QueryArtistsResponse = {
ids: artists.map((artist: any) => {

@ -8,11 +8,13 @@ const sequelizeOps: any = {
[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.artistIds]: "$Artists.id$",
[api.SongQueryElemProperty.albumIds]: "$Albums.id$",
@ -45,7 +47,7 @@ const getSequelizeWhere = (queryElem: api.SongQueryElem) => {
}
export const QuerySongsEndpointHandler: EndpointHandler = async (req: any, res: any) => {
if (!api.checkQuerySongsRequest(req)) {
if (!api.checkQuerySongsRequest(req.body)) {
const e: EndpointError = {
internalMessage: 'Invalid QuerySongs request: ' + JSON.stringify(req.body),
httpStatus: 400
@ -54,17 +56,39 @@ export const QuerySongsEndpointHandler: EndpointHandler = async (req: any, res:
}
const reqObject: api.QuerySongsRequest = req.body;
await models.Song.findAll({
try {
const songs = await models.Song.findAll({
where: getSequelizeWhere(reqObject.query),
include: [models.Artist, models.Album]
include: [models.Artist, models.Album, models.Tag],
limit: reqObject.limit,
offset: reqObject.offset,
})
.then((songs: any[]) => {
const response: api.QuerySongsResponse = {
ids: songs.map((song: any) => {
return song.id;
songs: await Promise.all(songs.map(async (song: any) => {
console.log("Song:", song, "artists:", song.getArtists());
const artists = await song.getArtists();
const tags = await song.getTags();
return <api.SongDetails>{
id: song.id,
title: song.title,
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,
}
})
};
}))
};
res.send(response);
})
.catch(catchUnhandledErrors);
} catch (e) {
catchUnhandledErrors(e);
}
}
Loading…
Cancel
Save