diff --git a/client/src/App.tsx b/client/src/App.tsx index 8825d6f..0a9bf6e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 { @@ -28,70 +31,41 @@ interface ArtistItemProps { const getStoreIcon = (url: String) => { if (url.includes('play.google.com')) { - return ; + return ; } - return ; + return ; } function SongItem(props: SongItemProps) { - const [songDisplayItem, setSongDisplayItem] = React.useState(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[] | 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 - } - }), - }; - }; - useEffect(() => { - updateSong().then((song: SongDisplayItem) => { setSongDisplayItem(song); }); - }, []); + 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 + // } + // }) + } - return ; + return ; } -function SongList() { - const [songs, setSongs] = useState([]); - - React.useEffect(() => { - const request: serverApi.QuerySongsRequest = { - query: {} - } - 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); - }); - }, []); - +interface SongListProps { + songs: serverApi.SongDetails[] +} +function SongList(props: SongListProps) { return - {songs.map((song: any) => { - return ; + {props.songs.map((song: any) => { + return ; })} ; @@ -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[] | 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({ + 'titleLike': '' + }); + const [songs, setSongs] = useState([]); + + 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() { + { setSongQuery(query); }} + /> - + diff --git a/client/src/api.ts b/client/src/api.ts index ebbc92e..f0d31c4 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -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). diff --git a/client/src/components/FilterControl.tsx b/client/src/components/FilterControl.tsx new file mode 100644 index 0000000..92e8e7c --- /dev/null +++ b/client/src/components/FilterControl.tsx @@ -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 props.onChangeQuery({ + titleLike: i.target.value + })} + /> +} + +interface ArtistFilterControlProps { + query: ArtistQuery, + onChangeQuery: (q: SongQuery) => void, +} +function ArtistFilterControl(props: ArtistFilterControlProps) { + return 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 + + {isTitleQuery(props.query) && } + {isArtistQuery(props.query) && } + ; +} \ No newline at end of file diff --git a/client/src/components/ItemListLoadedArtistItem.tsx b/client/src/components/ItemListLoadedArtistItem.tsx index 4d4c7a9..4120147 100644 --- a/client/src/components/ItemListLoadedArtistItem.tsx +++ b/client/src/components/ItemListLoadedArtistItem.tsx @@ -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) { + {props.item.tagNames.map((tag: any) => { + return + })} {props.item.storeLinks.map((link: any) => { return diff --git a/client/src/components/ItemListLoadedSongItem.tsx b/client/src/components/ItemListLoadedSongItem.tsx index 0fb5330..f2210b4 100644 --- a/client/src/components/ItemListLoadedSongItem.tsx +++ b/client/src/components/ItemListLoadedSongItem.tsx @@ -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 + })} {props.item.storeLinks.map((link: any) => { return diff --git a/client/src/types/DisplayItem.tsx b/client/src/types/DisplayItem.tsx index b7dd1f2..bfa76d0 100644 --- a/client/src/types/DisplayItem.tsx +++ b/client/src/types/DisplayItem.tsx @@ -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, diff --git a/client/src/types/Query.tsx b/client/src/types/Query.tsx new file mode 100644 index 0000000..4396088 --- /dev/null +++ b/client/src/types/Query.tsx @@ -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)) || {}; +} \ No newline at end of file diff --git a/scripts/gpm_retrieve/gpm_retrieve.py b/scripts/gpm_retrieve/gpm_retrieve.py index fba8965..4ca7aa4 100755 --- a/scripts/gpm_retrieve/gpm_retrieve.py +++ b/scripts/gpm_retrieve/gpm_retrieve.py @@ -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}") diff --git a/server/endpoints/CreateArtistEndpointHandler.ts b/server/endpoints/CreateArtistEndpointHandler.ts index 8d02ac8..22fb5ed 100644 --- a/server/endpoints/CreateArtistEndpointHandler.ts +++ b/server/endpoints/CreateArtistEndpointHandler.ts @@ -13,40 +13,40 @@ export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res } const reqObject: api.CreateArtistRequest = req.body; - // Start retrieving the tag instances to link the artist to. - var tagInstancesPromise = reqObject.tagIds && models.Tag.findAll({ - where: { - id: { - [Op.in]: reqObject.tagIds - } - } - }); + console.log("Create artist:", reqObject) - // Upon finish retrieving artists and albums, create the artist and associate it. - await Promise.all([tagInstancesPromise]) - .then((values: any) => { - var [tags] = values; + try { - if (reqObject.tagIds && tags.length !== reqObject.tagIds.length) { - const e: EndpointError = { - internalMessage: 'Not all atags exist for CreateArtist request: ' + JSON.stringify(req.body), - httpStatus: 400 - }; - throw e; + // Start retrieving the tag instances to link the artist to. + const tags = reqObject.tagIds && await models.Tag.findAll({ + where: { + id: { + [Op.in]: reqObject.tagIds + } } + }); - var artist = models.Artist.build({ - name: reqObject.name, - storeLinks: reqObject.storeLinks || [], - }); - tags && artist.addTags(tags); - return artist.save(); - }) - .then((artist: any) => { - const responseObject: api.CreateSongResponse = { - id: artist.id + console.log("Found artist tags:", tags) + + if (reqObject.tagIds && tags.length !== reqObject.tagIds.length) { + const e: EndpointError = { + internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body), + httpStatus: 400 }; - res.status(200).send(responseObject); - }) - .catch(catchUnhandledErrors); + throw e; + } + + var artist = models.Artist.build({ + name: reqObject.name, + storeLinks: reqObject.storeLinks || [], + }); + tags && artist.addTags(tags); + await artist.save(); + const responseObject: api.CreateSongResponse = { + id: artist.id + }; + await res.status(200).send(responseObject); + } catch (e) { + catchUnhandledErrors(e); + } } \ No newline at end of file diff --git a/server/endpoints/QueryArtistsEndpointHandler.ts b/server/endpoints/QueryArtistsEndpointHandler.ts index 30788bd..d521935 100644 --- a/server/endpoints/QueryArtistsEndpointHandler.ts +++ b/server/endpoints/QueryArtistsEndpointHandler.ts @@ -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) => { diff --git a/server/endpoints/QuerySongsEndpointHandler.ts b/server/endpoints/QuerySongsEndpointHandler.ts index 03e978f..7cf7e09 100644 --- a/server/endpoints/QuerySongsEndpointHandler.ts +++ b/server/endpoints/QuerySongsEndpointHandler.ts @@ -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({ - where: getSequelizeWhere(reqObject.query), - include: [models.Artist, models.Album] - }) - .then((songs: any[]) => { - const response: api.QuerySongsResponse = { - ids: songs.map((song: any) => { - return song.id; - }) - }; - res.send(response); + try { + const songs = await models.Song.findAll({ + where: getSequelizeWhere(reqObject.query), + include: [models.Artist, models.Album, models.Tag], + limit: reqObject.limit, + offset: reqObject.offset, }) - .catch(catchUnhandledErrors); + + const response: api.QuerySongsResponse = { + 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 { + id: song.id, + title: song.title, + artists: artists.map((artist: any) => { + return { + id: artist.id, + name: artist.name, + } + }), + tags: tags.map((tag: any) => { + return { + id: tag.id, + name: tag.name, + } + }) + }; + })) + }; + res.send(response); + } catch (e) { + catchUnhandledErrors(e); + } } \ No newline at end of file