diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 970cef6..d3b73f1 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -1,5 +1,5 @@ -import React, { useReducer, useState, Reducer } from 'react'; -import { ThemeProvider, CssBaseline, createMuiTheme, withWidth } from '@material-ui/core'; +import React, { useReducer, Reducer } from 'react'; +import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { grey } from '@material-ui/core/colors'; import AppBar from './appbar/AppBar'; import QueryWindow from './windows/QueryWindow'; @@ -48,7 +48,6 @@ export function MainWindowReducer(state: MainWindowState, action: any) { activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab, } case MainWindowStateActions.AddTab: - console.log("Add tab: ", action) return { ...state, tabStates: [...state.tabStates, action.tabState], @@ -65,17 +64,33 @@ export function MainWindowReducer(state: MainWindowState, action: any) { }) } default: - throw new Error("Unimplemented QueryWindow state update.") + throw new Error("Unimplemented MainWindow state update.") } } export default function MainWindow(props: any) { const [state, dispatch] = useReducer(MainWindowReducer, { tabStates: [ - newWindowState[WindowType.Query]() + newWindowState[WindowType.Query](), + newWindowState[WindowType.Song](), + newWindowState[WindowType.Album](), + newWindowState[WindowType.Artist](), + newWindowState[WindowType.Tag](), + ], + tabReducers: [ + newWindowReducer[WindowType.Query], + newWindowReducer[WindowType.Song], + newWindowReducer[WindowType.Album], + newWindowReducer[WindowType.Artist], + newWindowReducer[WindowType.Tag], + ], + tabTypes: [ + WindowType.Query, + WindowType.Song, + WindowType.Album, + WindowType.Artist, + WindowType.Tag, ], - tabReducers: [newWindowReducer[WindowType.Query]], - tabTypes: [WindowType.Query], activeTab: 0 }) diff --git a/client/src/components/appbar/AppBar.tsx b/client/src/components/appbar/AppBar.tsx index b7308aa..820b479 100644 --- a/client/src/components/appbar/AppBar.tsx +++ b/client/src/components/appbar/AppBar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import AddIcon from '@material-ui/icons/Add'; @@ -61,7 +61,12 @@ export default function AppBar(props: IProps) { error - props.setSelectedTab(v)}> + props.setSelectedTab(v)} + variant="scrollable" + scrollButtons="auto" + > {props.tabLabels.map((l: string, idx: number) => void, + onChangeChangedValue: (v: string | null) => void, +} + +export default function EditableText(props: IProps) { + let editingValue = props.editingValue; + let defaultValue = props.defaultValue; + let changedValue = props.changedValue; + let onChangeEditingValue = props.onChangeEditingValue; + let onChangeChangedValue = props.onChangeChangedValue; + let editing = editingValue !== null; + let editingLabel = props.editingLabel; + + const theme = useTheme(); + + const [hovering, setHovering] = useState(false); + + const editButton = + onChangeEditingValue(changedValue || defaultValue)} + > + + + + + const discardChangesButton = + { + onChangeChangedValue(null); + onChangeEditingValue(null); + }} + > + + + + + if (editing) { + return + onChangeEditingValue(e.target.value)} + /> + { + onChangeChangedValue(editingValue === defaultValue ? null : editingValue); + onChangeEditingValue(null); + }} + > + + } else if (changedValue) { + return setHovering(true)} + onMouseLeave={() => setHovering(false)} + display="flex" + alignItems="center" + > + {defaultValue}→ + {changedValue} + {editButton} + {discardChangesButton} + + } + + return setHovering(true)} + onMouseLeave={() => setHovering(false)} + display="flex" + alignItems="center" + >{defaultValue}{editButton}; +} \ No newline at end of file diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx new file mode 100644 index 0000000..7359ab0 --- /dev/null +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; + +export enum ExternalStore { + GooglePlayMusic = "GPM", +} + +export interface IProps { + whichStore: ExternalStore, +} + +export function whichStore(url: string) { + if(url.includes('play.google.com')) { + return ExternalStore.GooglePlayMusic; + } + return undefined; +} + +export default function StoreLinkIcon(props: any) { + const { whichStore, ...restProps } = props; + + switch(whichStore) { + case ExternalStore.GooglePlayMusic: + return ; + default: + throw new Error("Unknown external store: " + whichStore) + } +} \ No newline at end of file diff --git a/client/src/components/common/SubmitChangesButton.tsx b/client/src/components/common/SubmitChangesButton.tsx new file mode 100644 index 0000000..a6a794b --- /dev/null +++ b/client/src/components/common/SubmitChangesButton.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Box, Button } from '@material-ui/core'; + +export default function SubmitChangesButton(props: any) { + return + + +} \ No newline at end of file diff --git a/client/src/components/tables/ResultsTable.tsx b/client/src/components/tables/ResultsTable.tsx index 154bdaa..978a123 100644 --- a/client/src/components/tables/ResultsTable.tsx +++ b/client/src/components/tables/ResultsTable.tsx @@ -7,6 +7,7 @@ import PersonIcon from '@material-ui/icons/Person'; import AlbumIcon from '@material-ui/icons/Album'; import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import LocalOfferIcon from '@material-ui/icons/LocalOffer'; +import { songGetters } from '../../lib/songGetters'; export interface SongGetters { getTitle: (song: any) => string, @@ -25,7 +26,7 @@ export interface IProps { mainDispatch: (action: any) => void, } -export function SongTable(props: IProps) { +export default function SongTable(props: IProps) { const useTableStyles = makeStyles({ table: { minWidth: 650, @@ -66,6 +67,8 @@ export function SongTable(props: IProps) { tabLabel: <>{mainArtistName}, artistId: mainArtistId, metadata: null, + songGetters: songGetters, + songsByArtist: null, }, tabReducer: newWindowReducer[WindowType.Artist], tabType: WindowType.Artist, @@ -79,6 +82,8 @@ export function SongTable(props: IProps) { tabLabel: <>{mainAlbumName}, albumId: mainAlbumId, metadata: null, + songGetters: songGetters, + songsOnAlbum: null, }, tabReducer: newWindowReducer[WindowType.Album], tabType: WindowType.Album, @@ -118,7 +123,7 @@ export function SongTable(props: IProps) { return onClickTag(tagIds[i][tagIds[i].length-1], fullTag)} + onClick={() => onClickTag(tagIds[i][tagIds[i].length - 1], fullTag)} /> }); diff --git a/client/src/components/windows/AlbumWindow.tsx b/client/src/components/windows/AlbumWindow.tsx index 2ad17a8..582b7bc 100644 --- a/client/src/components/windows/AlbumWindow.tsx +++ b/client/src/components/windows/AlbumWindow.tsx @@ -1,26 +1,39 @@ -import React, { useEffect } from 'react'; -import { Box, Typography } from '@material-ui/core'; -import AlbumIcon from '@material-ui/icons/Album'; +import React, { useEffect, useState } from 'react'; +import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core'; +import PersonIcon from '@material-ui/icons/Person'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; +import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import EditableText from '../common/EditableText'; +import SubmitChangesButton from '../common/SubmitChangesButton'; +import SongTable, { SongGetters } from '../tables/ResultsTable'; +var _ = require('lodash'); -export interface AlbumMetadata { - name: string, -} +export type AlbumMetadata = serverApi.AlbumDetails; +export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest; export interface AlbumWindowState extends WindowState { albumId: number, metadata: AlbumMetadata | null, + pendingChanges: AlbumMetadataChanges | null, + songsOnAlbum: any[] | null, + songGetters: SongGetters, } export enum AlbumWindowStateActions { SetMetadata = "SetMetadata", + SetPendingChanges = "SetPendingChanges", + SetSongs = "SetSongs", } export function AlbumWindowReducer(state: AlbumWindowState, action: any) { switch (action.type) { case AlbumWindowStateActions.SetMetadata: return { ...state, metadata: action.value } + case AlbumWindowStateActions.SetPendingChanges: + return { ...state, pendingChanges: action.value } + case AlbumWindowStateActions.SetSongs: + return { ...state, songsOnAlbum: action.value } default: throw new Error("Unimplemented AlbumWindow state update.") } @@ -63,25 +76,96 @@ export async function getAlbumMetadata(id: number) { const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); let album = json.albums[0]; - return { - name: album.name - } + return album; })(); } export default function AlbumWindow(props: IProps) { let metadata = props.state.metadata; + let pendingChanges = props.state.pendingChanges; + // Effect to get the album's metadata. useEffect(() => { getAlbumMetadata(props.state.albumId) .then((m: AlbumMetadata) => { - console.log("metadata", m); props.dispatch({ type: AlbumWindowStateActions.SetMetadata, value: m }); }) - }, [props.state.metadata?.name]); + }, [metadata?.name]); + + // Effect to get the album's songs. + useEffect(() => { + if(props.state.songsOnAlbum) { return; } + + var q: serverApi.QueryRequest = { + query: { + prop: serverApi.QueryElemProperty.albumId, + propOperator: serverApi.QueryFilterOp.Eq, + propOperand: props.state.albumId, + }, + offsetsLimits: { + songOffset: 0, + songLimit: 100, + }, + ordering: { + orderBy: { + type: serverApi.OrderByType.Name, + }, + ascending: true, + }, + }; + + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(q), + }; + + (async () => { + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + let json: any = await response.json(); + props.dispatch({ + type: AlbumWindowStateActions.SetSongs, + value: json.songs, + }); + })(); + }, [props.state.songsOnAlbum]); + + const [editingName, setEditingName] = useState(null); + const name = setEditingName(v)} + onChangeChangedValue={(v: string | null) => { + let newVal: any = { ...pendingChanges }; + if (v) { newVal.name = v } + else { delete newVal.name } + props.dispatch({ + type: AlbumWindowStateActions.SetPendingChanges, + value: newVal, + }) + }} + /> + + const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { + const store = whichStore(link); + return store && + + + + }); + + const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 && + return - + + + + {metadata && + + {name} + + + + {storeLinks} + + + } + + + {maybeSubmitButton} - {metadata && {metadata.name}} + + Songs in this album in your library: + + {props.state.songsOnAlbum && } + {!props.state.songsOnAlbum && } } \ No newline at end of file diff --git a/client/src/components/windows/ArtistWindow.tsx b/client/src/components/windows/ArtistWindow.tsx index d3453eb..67dfb70 100644 --- a/client/src/components/windows/ArtistWindow.tsx +++ b/client/src/components/windows/ArtistWindow.tsx @@ -1,26 +1,39 @@ -import React, { useEffect } from 'react'; -import { Box, Typography } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core'; import PersonIcon from '@material-ui/icons/Person'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; +import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import EditableText from '../common/EditableText'; +import SubmitChangesButton from '../common/SubmitChangesButton'; +import SongTable, { SongGetters } from '../tables/ResultsTable'; +var _ = require('lodash'); -export interface ArtistMetadata { - name: string, -} +export type ArtistMetadata = serverApi.ArtistDetails; +export type ArtistMetadataChanges = serverApi.ModifyArtistRequest; export interface ArtistWindowState extends WindowState { artistId: number, metadata: ArtistMetadata | null, + pendingChanges: ArtistMetadataChanges | null, + songsByArtist: any[] | null, + songGetters: SongGetters, } export enum ArtistWindowStateActions { SetMetadata = "SetMetadata", + SetPendingChanges = "SetPendingChanges", + SetSongs = "SetSongs", } export function ArtistWindowReducer(state: ArtistWindowState, action: any) { switch (action.type) { case ArtistWindowStateActions.SetMetadata: return { ...state, metadata: action.value } + case ArtistWindowStateActions.SetPendingChanges: + return { ...state, pendingChanges: action.value } + case ArtistWindowStateActions.SetSongs: + return { ...state, songsByArtist: action.value } default: throw new Error("Unimplemented ArtistWindow state update.") } @@ -63,25 +76,96 @@ export async function getArtistMetadata(id: number) { const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); let artist = json.artists[0]; - return { - name: artist.name - } + return artist; })(); } export default function ArtistWindow(props: IProps) { let metadata = props.state.metadata; + let pendingChanges = props.state.pendingChanges; + // Effect to get the artist's metadata. useEffect(() => { getArtistMetadata(props.state.artistId) .then((m: ArtistMetadata) => { - console.log("metadata", m); props.dispatch({ type: ArtistWindowStateActions.SetMetadata, value: m }); }) - }, [props.state.metadata?.name]); + }, [metadata?.name]); + + // Effect to get the artist's songs. + useEffect(() => { + if(props.state.songsByArtist) { return; } + + var q: serverApi.QueryRequest = { + query: { + prop: serverApi.QueryElemProperty.artistId, + propOperator: serverApi.QueryFilterOp.Eq, + propOperand: props.state.artistId, + }, + offsetsLimits: { + songOffset: 0, + songLimit: 100, + }, + ordering: { + orderBy: { + type: serverApi.OrderByType.Name, + }, + ascending: true, + }, + }; + + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(q), + }; + + (async () => { + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + let json: any = await response.json(); + props.dispatch({ + type: ArtistWindowStateActions.SetSongs, + value: json.songs, + }); + })(); + }, [props.state.songsByArtist]); + + const [editingName, setEditingName] = useState(null); + const name = setEditingName(v)} + onChangeChangedValue={(v: string | null) => { + let newVal: any = { ...pendingChanges }; + if (v) { newVal.name = v } + else { delete newVal.name } + props.dispatch({ + type: ArtistWindowStateActions.SetPendingChanges, + value: newVal, + }) + }} + /> + + const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { + const store = whichStore(link); + return store && + + + + }); + + const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 && + return - + + + + {metadata && + + {name} + + + + {storeLinks} + + + } + + + {maybeSubmitButton} - {metadata && {metadata.name}} + + Songs by this artist in your library: + + {props.state.songsByArtist && } + {!props.state.songsByArtist && } } \ No newline at end of file diff --git a/client/src/components/windows/QueryWindow.tsx b/client/src/components/windows/QueryWindow.tsx index fa7507e..e35d233 100644 --- a/client/src/components/windows/QueryWindow.tsx +++ b/client/src/components/windows/QueryWindow.tsx @@ -3,8 +3,8 @@ import { createMuiTheme, Box, LinearProgress } from '@material-ui/core'; import { QueryElem, toApiQuery } from '../../lib/query/Query'; import QueryBuilder from '../querybuilder/QueryBuilder'; import * as serverApi from '../../api'; -import { SongTable } from '../tables/ResultsTable'; -import stringifyList from '../../lib/stringifyList'; +import SongTable from '../tables/ResultsTable'; +import { songGetters } from '../../lib/songGetters'; import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters'; import { grey } from '@material-ui/core/colors'; import { WindowState } from './Windows'; @@ -72,35 +72,6 @@ export default function QueryWindow(props: IProps) { const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query)); const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : []; - const songGetters = { - getTitle: (song: any) => song.title, - getId: (song: any) => song.songId, - getArtistNames: (song: any) => song.artists.map((a: any) => a.name), - getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId), - getAlbumNames: (song: any) => song.albums.map((a: any) => a.name), - getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId), - getTagNames: (song: any) => { - // Recursively resolve the name. - const resolveTag = (tag: any) => { - var r = [tag.name]; - if (tag.parent) { r.unshift(resolveTag(tag.parent)); } - return r; - } - - return song.tags.map((tag: any) => resolveTag(tag)); - }, - getTagIds: (song: any) => { - // Recursively resolve the id. - const resolveTag = (tag: any) => { - var r = [tag.tagId]; - if (tag.parent) { r.unshift(resolveTag(tag.parent)); } - return r; - } - - return song.tags.map((tag: any) => resolveTag(tag)); - }, - } - const doQuery = async (_query: QueryElem) => { var q: serverApi.QueryRequest = { query: toApiQuery(_query), diff --git a/client/src/components/windows/SongWindow.tsx b/client/src/components/windows/SongWindow.tsx index b937b8f..6c3aa2f 100644 --- a/client/src/components/windows/SongWindow.tsx +++ b/client/src/components/windows/SongWindow.tsx @@ -1,26 +1,36 @@ -import React, { useEffect } from 'react'; -import { Box, Typography } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { Box, Typography, IconButton, Button } from '@material-ui/core'; import AudiotrackIcon from '@material-ui/icons/Audiotrack'; +import PersonIcon from '@material-ui/icons/Person'; +import AlbumIcon from '@material-ui/icons/Album'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; +import { ArtistMetadata } from './ArtistWindow'; +import { AlbumMetadata } from './AlbumWindow'; +import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import EditableText from '../common/EditableText'; +import SubmitChangesButton from '../common/SubmitChangesButton'; -export interface SongMetadata { - title: string, -} +export type SongMetadata = serverApi.SongDetails; +export type SongMetadataChanges = serverApi.ModifySongRequest; export interface SongWindowState extends WindowState { songId: number, metadata: SongMetadata | null, + pendingChanges: SongMetadataChanges | null, } export enum SongWindowStateActions { SetMetadata = "SetMetadata", + SetPendingChanges = "SetPendingChanges", } export function SongWindowReducer(state: SongWindowState, action: any) { switch (action.type) { case SongWindowStateActions.SetMetadata: return { ...state, metadata: action.value } + case SongWindowStateActions.SetPendingChanges: + return { ...state, pendingChanges: action.value } default: throw new Error("Unimplemented SongWindow state update.") } @@ -63,25 +73,69 @@ export async function getSongMetadata(id: number) { const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); let song = json.songs[0]; - return { - title: song.title - } + return song; })(); } export default function SongWindow(props: IProps) { let metadata = props.state.metadata; + let pendingChanges = props.state.pendingChanges; useEffect(() => { getSongMetadata(props.state.songId) .then((m: SongMetadata) => { - console.log("metadata", m); props.dispatch({ type: SongWindowStateActions.SetMetadata, value: m }); }) - }, [props.state.metadata?.title]); + }, [metadata?.title]); + + const [editingTitle, setEditingTitle] = useState(null); + const title = setEditingTitle(v)} + onChangeChangedValue={(v: string | null) => { + let newVal: any = { ...pendingChanges }; + if(v) { newVal.title = v } + else { delete newVal.title } + props.dispatch({ + type: SongWindowStateActions.SetPendingChanges, + value: newVal, + }) + }} + /> + + const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => { + return + {artist.name} + + }); + + const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => { + return + {album.name} + + }); + + const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { + const store = whichStore(link); + return store && + + + + }); + + const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 && + return - + + + + {metadata && + + {title} + + + + + + {artists} + + + + + + + + {albums} + + + + + + {storeLinks} + + + } - {metadata && {metadata.title}} + {maybeSubmitButton} } \ No newline at end of file diff --git a/client/src/components/windows/TagWindow.tsx b/client/src/components/windows/TagWindow.tsx index 49c7f50..f504312 100644 --- a/client/src/components/windows/TagWindow.tsx +++ b/client/src/components/windows/TagWindow.tsx @@ -4,9 +4,7 @@ import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; -export interface TagMetadata { - name: string, -} +export type TagMetadata = serverApi.TagDetails; export interface TagWindowState extends WindowState { tagId: number, @@ -63,9 +61,7 @@ export async function getTagMetadata(id: number) { const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) let json: any = await response.json(); let tag = json.tags[0]; - return { - name: tag.name - } + return tag; })(); } diff --git a/client/src/components/windows/Windows.tsx b/client/src/components/windows/Windows.tsx index 77b79f3..7460ae0 100644 --- a/client/src/components/windows/Windows.tsx +++ b/client/src/components/windows/Windows.tsx @@ -9,6 +9,7 @@ import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import { SongWindowReducer } from './SongWindow'; import { AlbumWindowReducer } from './AlbumWindow'; import { TagWindowReducer } from './TagWindow'; +import { songGetters } from '../../lib/songGetters'; export enum WindowType { Query = "Query", @@ -41,30 +42,38 @@ export const newWindowState = { }, [WindowType.Artist]: () => { return { - tabLabel: <>Artist, + tabLabel: <>Artist 1, artistId: 1, metadata: null, + pendingChanges: null, + songGetters: songGetters, + songsByArtist: null, } }, [WindowType.Album]: () => { return { - tabLabel: <>Album, + tabLabel: <>Album 1, albumId: 1, metadata: null, + pendingChanges: null, + songGetters: songGetters, + songsOnAlbum: null, } }, [WindowType.Song]: () => { return { - tabLabel: <>Song, + tabLabel: <>Song 1, songId: 1, metadata: null, + pendingChanges: null, } }, [WindowType.Tag]: () => { return { - tabLabel: <>Tag, + tabLabel: <>Tag 1, tagId: 1, metadata: null, + pendingChanges: null, } }, } \ No newline at end of file diff --git a/client/src/lib/songGetters.tsx b/client/src/lib/songGetters.tsx new file mode 100644 index 0000000..4f75cc9 --- /dev/null +++ b/client/src/lib/songGetters.tsx @@ -0,0 +1,28 @@ +export const songGetters = { + getTitle: (song: any) => song.title, + getId: (song: any) => song.songId, + getArtistNames: (song: any) => song.artists.map((a: any) => a.name), + getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId), + getAlbumNames: (song: any) => song.albums.map((a: any) => a.name), + getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId), + getTagNames: (song: any) => { + // Recursively resolve the name. + const resolveTag = (tag: any) => { + var r = [tag.name]; + if (tag.parent) { r.unshift(resolveTag(tag.parent)); } + return r; + } + + return song.tags.map((tag: any) => resolveTag(tag)); + }, + getTagIds: (song: any) => { + // Recursively resolve the id. + const resolveTag = (tag: any) => { + var r = [tag.tagId]; + if (tag.parent) { r.unshift(resolveTag(tag.parent)); } + return r; + } + + return song.tags.map((tag: any) => resolveTag(tag)); + }, +} \ No newline at end of file