diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0bd2cb5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "client/submodules/youtube-music-api"] + path = client/submodules/youtube-music-api + url = https://github.com/SanderVocke/youtube-music-api.git +[submodule "client/src/submodules/youtube-music-api"] + path = client/src/submodules/youtube-music-api + url = https://github.com/SanderVocke/youtube-music-api.git diff --git a/client/package-lock.json b/client/package-lock.json index b8c35fe..da974b8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1837,6 +1837,14 @@ "hoist-non-react-statics": "^3.3.0" } }, + "@types/http-proxy": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz", + "integrity": "sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==", + "requires": { + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -2732,6 +2740,14 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz", "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==" }, + "axios": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -6905,14 +6921,55 @@ } }, "http-proxy-middleware": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz", - "integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.0.6.tgz", + "integrity": "sha512-NyL6ZB6cVni7pl+/IT2W0ni5ME00xR0sN27AQZZrpKn1b+qRh+mLbBxIq9Cq1oGfmTc7BUq4HB77mxwCaxAYNg==", "requires": { - "http-proxy": "^1.17.0", - "is-glob": "^4.0.0", - "lodash": "^4.17.11", - "micromatch": "^3.1.10" + "@types/http-proxy": "^1.17.4", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "lodash": "^4.17.20", + "micromatch": "^4.0.2" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + } } }, "http-signature": { @@ -14106,6 +14163,17 @@ } } }, + "http-proxy-middleware": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz", + "integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==", + "requires": { + "http-proxy": "^1.17.0", + "is-glob": "^4.0.0", + "lodash": "^4.17.11", + "micromatch": "^3.1.10" + } + }, "is-absolute-url": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", diff --git a/client/package.json b/client/package.json index 1fb3ac0..3e23ba5 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,8 @@ "@types/react-router": "^5.1.8", "@types/react-router-dom": "^5.1.5", "@types/uuid": "^8.3.0", + "axios": "^0.21.0", + "http-proxy-middleware": "^1.0.6", "jsurl": "^0.1.5", "lodash": "^4.17.20", "material-table": "^1.69.0", @@ -49,6 +51,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "proxy": "http://localhost:5000/" + } } diff --git a/client/src/assets/youtubemusic_icon.svg b/client/src/assets/youtubemusic_icon.svg new file mode 100644 index 0000000..97f8ee6 --- /dev/null +++ b/client/src/assets/youtubemusic_icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index d600867..df71822 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -1,4 +1,4 @@ -import React, { useReducer, Reducer } from 'react'; +import React, { useReducer, Reducer, useEffect } from 'react'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { grey } from '@material-ui/core/colors'; import AppBar from './appbar/AppBar'; @@ -10,6 +10,8 @@ import AlbumWindow from './windows/album/AlbumWindow'; import TagWindow from './windows/tag/TagWindow'; import SongWindow from './windows/song/SongWindow'; import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow'; +import Integration from '../integrations/Integration'; +import YoutubeMusicIntegration from '../integrations/youtube_music/YoutubeMusicIntegration'; var _ = require('lodash'); const darkTheme = createMuiTheme({ @@ -26,6 +28,7 @@ export interface MainWindowState { tabReducers: Reducer[], tabTypes: WindowType[], activeTab: number, + activeIntegrations: Integration[], } export enum MainWindowStateActions { @@ -33,6 +36,7 @@ export enum MainWindowStateActions { DispatchToTab = "dispatchToTab", CloseTab = "closeTab", AddTab = "addTab", + AddIntegration = "addIntegration", } export function MainWindowReducer(state: MainWindowState, action: any) { @@ -64,6 +68,11 @@ export function MainWindowReducer(state: MainWindowState, action: any) { item; }) } + case MainWindowStateActions.AddIntegration: + return { + ...state, + activeIntegrations: [ ...state.activeIntegrations, action.value ] + } default: throw new Error("Unimplemented MainWindow state update.") } @@ -95,9 +104,20 @@ export default function MainWindow(props: any) { WindowType.Tag, WindowType.ManageTags, ], - activeTab: 0 + activeTab: 0, + activeIntegrations: [], }) + useEffect(() => { + const integration = new YoutubeMusicIntegration(); + integration.connect({}).then(() => { + dispatch({ + type: MainWindowStateActions.AddIntegration, + value: integration, + }) + }) + }, []); + const windows = state.tabStates.map((tabState: any, i: number) => { const tabDispatch = (action: any) => { dispatch({ @@ -164,6 +184,7 @@ export default function MainWindow(props: any) { tabType: w.windowType, }) }} + integrations={state.activeIntegrations} /> {windows[state.activeTab]} diff --git a/client/src/components/appbar/AppBar.tsx b/client/src/components/appbar/AppBar.tsx index 820b479..7ef1773 100644 --- a/client/src/components/appbar/AppBar.tsx +++ b/client/src/components/appbar/AppBar.tsx @@ -3,6 +3,8 @@ import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@mate import CloseIcon from '@material-ui/icons/Close'; import AddIcon from '@material-ui/icons/Add'; import AddTabMenu, { NewTabProps } from './AddTabMenu'; +import Integration from '../../integrations/Integration'; +import IntegrationsWidget from './IntegrationsWidget'; export interface IProps { tabLabels: string[], @@ -10,6 +12,7 @@ export interface IProps { setSelectedTab: (n: number) => void, onCloseTab: (idx: number) => void, onAddTab: (w: NewTabProps) => void, + integrations: Integration[], } export interface TabProps { @@ -61,6 +64,9 @@ export default function AppBar(props: IProps) { error + + + props.setSelectedTab(v)} diff --git a/client/src/components/appbar/IntegrationsWidget.tsx b/client/src/components/appbar/IntegrationsWidget.tsx new file mode 100644 index 0000000..6367894 --- /dev/null +++ b/client/src/components/appbar/IntegrationsWidget.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Integration from '../../integrations/Integration'; + +export default function IntegrationsWidget(props: { + integrations: Integration[] +}) { + return <> + {props.integrations.map((integration: Integration) => { + return integration.getIcon({ style: { height: '30px', width: '30px' } }); + })} + +} \ No newline at end of file diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index 7359ab0..78b05c8 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; +import { ReactComponent as YTMIcon } from '../../assets/youtubemusic_icon.svg'; export enum ExternalStore { - GooglePlayMusic = "GPM", + GooglePlayMusic = "GooglePlayMusic", + YoutubeMusic = "YoutubeMusic", } export interface IProps { @@ -10,9 +12,12 @@ export interface IProps { } export function whichStore(url: string) { - if(url.includes('play.google.com')) { + if(url.includes('play.google.com') || url.includes('music.google.com')) { return ExternalStore.GooglePlayMusic; } + if(url.includes('music.youtube.com')) { + return ExternalStore.YoutubeMusic; + } return undefined; } @@ -22,6 +27,8 @@ export default function StoreLinkIcon(props: any) { switch(whichStore) { case ExternalStore.GooglePlayMusic: return ; + case ExternalStore.YoutubeMusic: + return default: throw new Error("Unknown external store: " + whichStore) } diff --git a/client/src/integrations/Integration.tsx b/client/src/integrations/Integration.tsx new file mode 100644 index 0000000..a67781b --- /dev/null +++ b/client/src/integrations/Integration.tsx @@ -0,0 +1,52 @@ +import React, { ReactFragment } from 'react'; + +export interface IntegrationAlbum { + name?: string, + storeLink?: string, +} + +export interface IntegrationArtist { + name?: string, + storeLink?: string, +} + +export interface IntegrationSong { + title?: string, + album?: IntegrationAlbum, + artist?: IntegrationArtist, + storeLink?: string, +} + +export enum IntegrationFeature { + // Used to get a bucket of songs (typically: the whole library) + GetSongs = 0, + + // Used to search songs and get some amount of candidate results. + SearchSong, + + // If included, the integration is required to be connected to work. + // Methods connect() and isConnected() have to be used for this. + UsesConnection, +} + +export interface IntegrationDescriptor { + supports: IntegrationFeature[], +} + +export default class Integration { + constructor() { } + + // Common + getFeatures(): IntegrationFeature[] { return []; } + getIcon(props: any): ReactFragment { return <> } + + // Feature: UsesConnection + async connect(connectParams: any) { } + isConnected(): boolean { return false; } + + // Feature: GetSongs + async getSongs(getSongsParams: any): Promise { return []; } + + // Feature: SearchSongs + async searchSong(songProps: IntegrationSong): Promise { return null; } +} \ No newline at end of file diff --git a/client/src/integrations/youtube_music/YoutubeMusicIntegration.tsx b/client/src/integrations/youtube_music/YoutubeMusicIntegration.tsx new file mode 100644 index 0000000..674bf1a --- /dev/null +++ b/client/src/integrations/youtube_music/YoutubeMusicIntegration.tsx @@ -0,0 +1,55 @@ + +import React from 'react'; +import Integration, { IntegrationFeature, IntegrationSong } from "../Integration"; +import StoreLinkIcon, { ExternalStore } from "../../components/common/StoreLinkIcon"; +const YoutubeMusicApi = require('../../submodules/youtube-music-api'); + +export interface YoutubeMusicConnectionParams {} + +export default class YoutubeMusicIntegration extends Integration { + lastConnected: Date | null = null; + connectionIntervalMs: number = 1000 * 60 * 2; // 2 minutes + connectionParams: YoutubeMusicConnectionParams | null = null; + api: any = null; + + getFeatures() : IntegrationFeature[] { + return [ + IntegrationFeature.SearchSong, + IntegrationFeature.UsesConnection, + ] + } + + getIcon(props: any) { + return + } + + async connect(connectionParams: YoutubeMusicConnectionParams) { + const newApi = new YoutubeMusicApi(); + await newApi.initalize(); + this.api = newApi; + this.lastConnected = new Date(); + + // TODO start keepalive service worker? + } + + isConnected() { + if(!this.connectionParams || !this.lastConnected) { + return false; + } + + // Last successful connection must be less than 2 minutes ago. + return ((new Date().valueOf()) - this.lastConnected.valueOf()) < this.connectionIntervalMs; + } + + async searchSong(songProps: IntegrationSong) { + var query: string = ""; + if(songProps.title) { query += " " + songProps.title } + if(songProps.artist && songProps.artist.name) { query += " " + songProps.artist.name } + + const result = await this.api.search(query.trim(), "song"); + + console.log("Search result: ", result) + + return null; + } +} \ No newline at end of file diff --git a/client/src/setupProxy.js b/client/src/setupProxy.js new file mode 100644 index 0000000..aad8601 --- /dev/null +++ b/client/src/setupProxy.js @@ -0,0 +1,18 @@ +const { createProxyMiddleware } = require('http-proxy-middleware'); + +module.exports = function(app) { + app.use( + '/api', + createProxyMiddleware({ + target: 'http://localhost:5000', + changeOrigin: true, + }) + ); + app.use( + '/integrations/ytm', + createProxyMiddleware({ + target: 'https://music.youtube.com', + changeOrigin: true, + }) + ); +}; \ No newline at end of file diff --git a/client/src/submodules/youtube-music-api b/client/src/submodules/youtube-music-api new file mode 160000 index 0000000..0184b80 --- /dev/null +++ b/client/src/submodules/youtube-music-api @@ -0,0 +1 @@ +Subproject commit 0184b80d4b36f92b2874a0aece6e01d92af0c92c