diff --git a/client/package-lock.json b/client/package-lock.json
index 74d3f63..3718ad2 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -13368,6 +13368,11 @@
"punycode": "^2.1.0"
}
},
+ "ts-enum-util": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/ts-enum-util/-/ts-enum-util-4.0.2.tgz",
+ "integrity": "sha512-BB5qjvHYgYgOB/CaoA1Cy/B2QNnZ+nVBrJ15VV/AXGWx+AO83k5wgeLOJvkSLoKKavvH/M8Wj4ZbgROjsuYwzw=="
+ },
"ts-pnp": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.6.tgz",
diff --git a/client/package.json b/client/package.json
index 7c82c01..444fa2b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -27,6 +27,7 @@
"react-error-boundary": "^3.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.3",
+ "ts-enum-util": "^4.0.2",
"typescript": "~3.7.2",
"uuid": "^8.3.0"
},
diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx
index 8b3478e..c35fd75 100644
--- a/client/src/components/common/StoreLinkIcon.tsx
+++ b/client/src/components/common/StoreLinkIcon.tsx
@@ -3,7 +3,7 @@ import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg
import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg';
export enum ExternalStore {
- GooglePlayMusic = "GPM",
+ GooglePlayMusic = "Google Play Music",
Spotify = "Spotify",
}
@@ -12,20 +12,25 @@ export interface IProps {
}
export function whichStore(url: string) {
- if(url.includes('play.google.com')) {
+ if (url.includes('play.google.com')) {
return ExternalStore.GooglePlayMusic;
+ } else if (url.includes('spotify.com')) {
+ return ExternalStore.Spotify;
}
return undefined;
}
export default function StoreLinkIcon(props: any) {
- const { whichStore, ...restProps } = props;
+ const { whichStore, style, ...restProps } = props;
- switch(whichStore) {
+ let realStyle = (style === undefined) ?
+ { height: '40px', width: '40px' } : style;
+
+ switch (whichStore) {
case ExternalStore.GooglePlayMusic:
- return ;
+ return ;
case ExternalStore.Spotify:
- return ;
+ return ;
default:
throw new Error("Unknown external store: " + whichStore)
}
diff --git a/client/src/components/windows/settings/IntegrationSettings.tsx b/client/src/components/windows/settings/IntegrationSettings.tsx
index dfb7ade..928fd15 100644
--- a/client/src/components/windows/settings/IntegrationSettings.tsx
+++ b/client/src/components/windows/settings/IntegrationSettings.tsx
@@ -79,7 +79,7 @@ function EditIntegration(props: {
[serverApi.IntegrationType.SpotifyClientCredentials]:
- {IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({
+ {new IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials](-1).getIcon({
style: { height: '40px', width: '40px' }
})}
diff --git a/client/src/components/windows/song/EditSongDialog.tsx b/client/src/components/windows/song/EditSongDialog.tsx
new file mode 100644
index 0000000..30b0228
--- /dev/null
+++ b/client/src/components/windows/song/EditSongDialog.tsx
@@ -0,0 +1,196 @@
+import React, { useEffect, useState } from 'react';
+import { AppBar, Box, Button, Dialog, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core";
+import { SongMetadata } from "./SongWindow";
+import StoreLinkIcon, { ExternalStore, whichStore } from '../../common/StoreLinkIcon';
+import CheckIcon from '@material-ui/icons/Check';
+import CancelIcon from '@material-ui/icons/Cancel';
+import OpenInNewIcon from '@material-ui/icons/OpenInNew';
+import DeleteIcon from '@material-ui/icons/Delete';
+import { $enum } from "ts-enum-util";
+import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations';
+import { IntegrationFeature, IntegrationSong } from '../../../lib/integration/Integration';
+import { TabPanel } from '@material-ui/lab';
+import { v1 } from 'uuid';
+let _ = require('lodash')
+
+export function ProvideLinksWidget(props: {
+ providers: IntegrationState[],
+ metadata: SongMetadata,
+ store: ExternalStore,
+ onChange: (link: string | undefined) => void,
+}) {
+ let [selectedProviderIdx, setSelectedProviderIdx] = useState(
+ props.providers.length > 0 ? 0 : undefined
+ );
+ let [query, setQuery] = useState(
+ `${props.metadata.title} ${props.metadata.artists && props.metadata.artists[0].name}`
+ )
+ let [results, setResults] = useState([]);
+
+ let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
+ props.providers[selectedProviderIdx] : undefined;
+
+ let currentLink = props.metadata.storeLinks ? props.metadata.storeLinks.find(
+ (l: string) => whichStore(l) === props.store
+ ) : undefined;
+
+ return
+
+
+ setQuery(e.target.value)}
+ />
+
+
+
+ props.onChange(e.target.value)}>
+ {results.map((result: IntegrationSong, idx: number) => {
+ let pretty = `"${result.title}"
+ ${result.artist && ` by ${result.artist.name}`}
+ ${result.album && ` (${result.album.name})`}`;
+ return }
+ label={
+ {pretty}
+
+
+
+ }
+ />
+ })}
+
+
+
+}
+
+export function ExternalLinksEditor(props: {
+ metadata: SongMetadata,
+ onChange: (v: SongMetadata) => void,
+}) {
+ let [selectedIdx, setSelectedIdx] = useState(0);
+ let integrations = useIntegrations();
+
+ let linksSet: Record =
+ $enum(ExternalStore).getValues().reduce((prev: any, store: string) => {
+ var maybeLink: string | null = null;
+ props.metadata.storeLinks && props.metadata.storeLinks.forEach((link: string) => {
+ if (whichStore(link) === store) {
+ maybeLink = link;
+ }
+ })
+ return {
+ ...prev,
+ [store]: maybeLink,
+ }
+ }, {})
+
+ let store = $enum(ExternalStore).getValues()[selectedIdx];
+ let providers: IntegrationState[] = Array.isArray(integrations.state) ?
+ integrations.state.filter(
+ (iState: IntegrationState) => (
+ iState.integration.getFeatures().includes(IntegrationFeature.SearchSong) &&
+ iState.integration.providesStoreLink() === store
+ )
+ ) : [];
+
+ return
+
+
+
+ {$enum(ExternalStore).getValues().map((store: string, idx: number) => {
+ let maybeLink = linksSet[store];
+ return setSelectedIdx(idx)}
+ button
+ >
+ {linksSet[store] !== null ? : }
+
+
+ {maybeLink &&
+
+ }
+ {maybeLink && {
+ let newLinks = props.metadata.storeLinks?.filter(
+ (l: string) => whichStore(l) !== store
+ )
+ props.onChange({
+ ...props.metadata,
+ storeLinks: newLinks,
+ });
+ }}
+ >
+ }
+
+ })}
+
+
+
+ {providers.length === 0 ?
+ None of your configured integrations provides URL links for {store}. :
+ {
+ let removed = props.metadata.storeLinks?.filter(
+ (link: string) => whichStore(link) !== store
+ ) || [];
+ let newValue = link ? [...removed, link] : removed;
+ if (!_.isEqual(new Set(newValue), new Set(props.metadata.storeLinks || []))) {
+ props.onChange({
+ ...props.metadata,
+ storeLinks: newValue,
+ })
+ }
+ }}
+ />
+ }
+
+
+}
+
+export default function EditSongDialog(props: {
+ open: boolean,
+ onClose: () => void,
+ onSubmit: (v: SongMetadata) => void,
+ id: number,
+ metadata: SongMetadata,
+}) {
+ enum EditSongTabs {
+ Details = 0,
+ ExternalLinks,
+ }
+
+ let [editingMetadata, setEditingMetadata] = useState(props.metadata);
+ let [activeTab, setActiveTab] = useState(EditSongTabs.Details);
+
+ return
+
+}
\ No newline at end of file
diff --git a/client/src/components/windows/song/SongWindow.tsx b/client/src/components/windows/song/SongWindow.tsx
index 7cf86ac..b8f761d 100644
--- a/client/src/components/windows/song/SongWindow.tsx
+++ b/client/src/components/windows/song/SongWindow.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useReducer } from 'react';
-import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
+import { Box, Typography, IconButton } 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';
@@ -8,12 +8,11 @@ import { WindowState } from '../Windows';
import { ArtistMetadata } from '../artist/ArtistWindow';
import { AlbumMetadata } from '../album/AlbumWindow';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
-import EditableText from '../../common/EditableText';
-import SubmitChangesButton from '../../common/SubmitChangesButton';
-import { saveSongChanges } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { querySongs } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
+import EditSongDialog from './EditSongDialog';
+import EditIcon from '@material-ui/icons/Edit';
export type SongMetadata = serverApi.SongDetails;
@@ -66,6 +65,7 @@ export function SongWindowControlled(props: {
}) {
let { metadata, id: songId } = props.state;
let { dispatch } = props;
+ let [editing, setEditing] = useState(false);
useEffect(() => {
getSongMetadata(songId)
@@ -141,7 +141,19 @@ export function SongWindowControlled(props: {
{storeLinks}
+
+ { setEditing(true); }}
+ >
+
}
+ {metadata && { setEditing(false); }}
+ onSubmit={() => { }}
+ id={songId}
+ metadata={metadata}
+ />}
}
\ No newline at end of file
diff --git a/client/src/lib/integration/Integration.tsx b/client/src/lib/integration/Integration.tsx
index 441262f..5ab9e51 100644
--- a/client/src/lib/integration/Integration.tsx
+++ b/client/src/lib/integration/Integration.tsx
@@ -1,4 +1,5 @@
import React, { ReactFragment } from 'react';
+import { ExternalStore } from '../../components/common/StoreLinkIcon';
export interface IntegrationAlbum {
name?: string,
@@ -39,8 +40,9 @@ export default class Integration {
constructor(integrationId: number) { }
// Common
- static getFeatures(): IntegrationFeature[] { return []; }
- static getIcon(props: any): ReactFragment { return <>> }
+ getFeatures(): IntegrationFeature[] { return []; }
+ getIcon(props: any): ReactFragment { return <>> }
+ providesStoreLink(): ExternalStore | null { return null; }
// Requires feature: Test
async test(testParams: any): Promise {}
@@ -49,11 +51,11 @@ export default class Integration {
async getSongs(getSongsParams: any): Promise { return []; }
// Requires feature: SearchSongs
- async searchSong(songProps: IntegrationSong): Promise { return []; }
+ async searchSong(query: string, limit: number): Promise { return []; }
// Requires feature: SearchAlbum
- async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; }
+ async searchAlbum(query: string, limit: number): Promise { return []; }
// Requires feature: SearchArtist
- async searchArtist(artistProps: IntegrationArtist): Promise { return []; }
+ async searchArtist(query: string, limit: number): Promise { return []; }
}
\ No newline at end of file
diff --git a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx
index d211907..737fe05 100644
--- a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx
+++ b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx
@@ -3,7 +3,7 @@ import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, I
import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon';
enum SearchType {
- Song = 'song',
+ Song = 'track',
Artist = 'artist',
Album = 'album',
};
@@ -16,7 +16,7 @@ export default class SpotifyClientCreds extends Integration {
this.integrationId = integrationId;
}
- static getFeatures(): IntegrationFeature[] {
+ getFeatures(): IntegrationFeature[] {
return [
IntegrationFeature.Test,
IntegrationFeature.SearchSong,
@@ -25,53 +25,63 @@ export default class SpotifyClientCreds extends Integration {
]
}
- static getIcon(props: any) {
+ getIcon(props: any) {
return
}
+ providesStoreLink() {
+ return ExternalStore.Spotify;
+ }
+
async test(testParams: {}) {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
if (!response.ok) {
- throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response));
+ throw new Error("Spttify Client Credentials test failed: " + JSON.stringify(response));
}
}
- async searchSong(songProps: IntegrationSong): Promise { return []; }
- async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; }
- async searchArtist(artistProps: IntegrationArtist): Promise { return []; }
+ async searchSong(query: string, limit: number): Promise {
+ return this.search(query, SearchType.Song, limit);
+ }
+ async searchAlbum(query: string, limit: number): Promise { return []; }
+ async searchArtist(query: string, limit: number): Promise { return []; }
- async search(query: string, type: SearchType):
+ async search(query: string, type: SearchType, limit: number):
Promise {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
- `/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`);
+ `/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}`);
if (!response.ok) {
- throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response));
+ throw new Error("Spotify Client Credentials search failed: " + JSON.stringify(response));
}
+ let json = await response.json();
+
+ console.log("Response:", json);
+
switch(type) {
case SearchType.Song: {
- return (await response.json()).tracks.items.map((r: any): IntegrationSong => {
+ return json.tracks.items.map((r: any): IntegrationSong => {
return {
title: r.name,
url: r.external_urls.spotify,
artist: {
- name: r.artists[0].name,
- url: r.artists[0].external_urls.spotify,
+ name: r.artists && r.artists[0].name,
+ url: r.artists && r.artists[0].external_urls.spotify,
},
album: {
- name: r.albums[0].name,
- url: r.albums[0].external_urls.spotify,
+ name: r.album && r.album.name,
+ url: r.album && r.album.external_urls.spotify,
}
}
})
}
case SearchType.Artist: {
- return (await response.json()).artists.items.map((r: any): IntegrationArtist => {
+ return json.artists.items.map((r: any): IntegrationArtist => {
return {
name: r.name,
url: r.external_urls.spotify,
@@ -79,7 +89,7 @@ export default class SpotifyClientCreds extends Integration {
})
}
case SearchType.Album: {
- return (await response.json()).albums.items.map((r: any): IntegrationAlbum => {
+ return json.albums.items.map((r: any): IntegrationAlbum => {
return {
name: r.name,
url: r.external_urls.spotify,
diff --git a/server/package-lock.json b/server/package-lock.json
index 7d49ca9..13702bb 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -3927,6 +3927,11 @@
"punycode": "^2.1.1"
}
},
+ "ts-enum-util": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/ts-enum-util/-/ts-enum-util-4.0.2.tgz",
+ "integrity": "sha512-BB5qjvHYgYgOB/CaoA1Cy/B2QNnZ+nVBrJ15VV/AXGWx+AO83k5wgeLOJvkSLoKKavvH/M8Wj4ZbgROjsuYwzw=="
+ },
"ts-node": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz",
diff --git a/server/package.json b/server/package.json
index a05b69b..ae78a00 100644
--- a/server/package.json
+++ b/server/package.json
@@ -30,6 +30,7 @@
"pg": "^8.3.3",
"querystring": "^0.2.0",
"sqlite3": "^5.0.0",
+ "ts-enum-util": "^4.0.2",
"ts-node": "^8.10.2",
"typescript": "~3.7.2"
}