diff --git a/.gitignore b/.gitignore
index 15ef0f8..73492ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,6 +92,7 @@ __pycache__
spotifytovec.p
tracktovec.p
spotify_tracks.p
+spotify_urls.p
speccy_model
deejai.db
deejai-test.db
diff --git a/backend/deejai.py b/backend/deejai.py
index 9e889f3..2d17966 100644
--- a/backend/deejai.py
+++ b/backend/deejai.py
@@ -14,7 +14,8 @@
import numpy as np
from starlette.concurrency import run_in_threadpool
-if 'HACKINTOSH' not in os.environ: # can't get tensorflow to work on Hackintosh due to missing AVX support
+# can't get tensorflow to work on Hackintosh due to missing AVX support
+if 'HACKINTOSH' not in os.environ:
import tensorflow as tf
from keras.models import load_model
@@ -41,6 +42,8 @@ def __init__(self):
]))
with open('spotify_tracks.p', 'rb') as file:
self.tracks = pickle.load(file)
+ with open('spotify_urls.p', 'rb') as file:
+ self.urls = pickle.load(file)
self.track_ids = list(mp3tovecs)
self.track_indices = dict(
map(lambda x: (x[1], x[0]), enumerate(mp3tovecs)))
@@ -48,11 +51,12 @@ def __init__(self):
for _ in mp3tovecs])
del mp3tovecs, tracktovecs
if 'HACKINTOSH' not in os.environ:
- self.model = load_model('speccy_model',
- custom_objects={
- 'cosine_proximity':
- tf.compat.v1.keras.losses.cosine_proximity
- })
+ self.model = load_model(
+ 'speccy_model',
+ custom_objects={
+ 'cosine_proximity':
+ tf.compat.v1.keras.losses.cosine_proximity
+ })
def get_tracks(self):
"""Get tracks.
@@ -106,13 +110,14 @@ async def playlist(self, track_ids, size, creativity, noise):
size=size,
noise=noise)
- async def most_similar(self, # pylint: disable=too-many-arguments
- mp3tovecs,
- weights,
- positive=iter(()),
- negative=iter(()),
- noise=0,
- vecs=None):
+ async def most_similar(
+ self, # pylint: disable=too-many-arguments
+ mp3tovecs,
+ weights,
+ positive=iter(()),
+ negative=iter(()),
+ noise=0,
+ vecs=None):
"""Most similar IDs.
"""
mp3_vecs_i = np.array([
@@ -137,12 +142,13 @@ async def most_similar(self, # pylint: disable=too-many-arguments
del result[result.index(i)]
return result
- async def most_similar_by_vec(self, # pylint: disable=too-many-arguments
- mp3tovecs,
- weights,
- positives=iter(()),
- negatives=iter(()),
- noise=0):
+ async def most_similar_by_vec(
+ self, # pylint: disable=too-many-arguments
+ mp3tovecs,
+ weights,
+ positives=iter(()),
+ negatives=iter(()),
+ noise=0):
"""Most similar IDs by vector.
"""
mp3_vecs_i = np.array([
@@ -191,12 +197,13 @@ async def join_the_dots(self, weights, ids, size=5, noise=0):
playlist.append(end)
return playlist
- async def make_playlist(self, # pylint: disable=too-many-arguments
- weights,
- playlist,
- size=10,
- lookback=3,
- noise=0):
+ async def make_playlist(
+ self, # pylint: disable=too-many-arguments
+ weights,
+ playlist,
+ size=10,
+ lookback=3,
+ noise=0):
"""Generate playlist starting from seed track(s).
"""
playlist_tracks = [self.tracks[_] for _ in playlist]
diff --git a/backend/main.py b/backend/main.py
index f265fee..bc9d1ca 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -3,6 +3,7 @@
import os
import re
+import json
import urllib
import logging
from typing import Optional
@@ -23,6 +24,8 @@
import aiohttp
+from bs4 import BeautifulSoup
+
from . import models
from . import schemas
from . import credentials
@@ -162,18 +165,19 @@ async def spotify_callback(code: str, state: Optional[str] = '/'):
if response.status != 200:
raise HTTPException(status_code=response.status,
detail=response.reason)
- json = await response.json()
+ _json = await response.json()
except aiohttp.ClientError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
body = {
- 'access_token': json['access_token'],
- 'refresh_token': json['refresh_token'],
+ 'access_token': _json['access_token'],
+ 'refresh_token': _json['refresh_token'],
'route': state
}
if state.startswith('deejai://'):
url = state + '?' + urllib.parse.urlencode(body)
else:
- url = os.environ.get('APP_URL', '') + '#' + urllib.parse.urlencode(body)
+ url = os.environ.get('APP_URL',
+ '') + '#' + urllib.parse.urlencode(body)
return RedirectResponse(url=url)
@@ -202,10 +206,10 @@ async def spotify_refresh_token(refresh_token: str):
if response.status != 200:
raise HTTPException(status_code=response.status,
detail=response.reason)
- json = await response.json()
+ _json = await response.json()
except aiohttp.ClientError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
- return json
+ return _json
@app.get('/api/v1/widget')
@@ -234,7 +238,74 @@ async def widget(track_id: str):
text = await response.text()
except aiohttp.ClientError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
- return b64encode(text.encode('ascii'))
+ soup = BeautifulSoup(text, 'html.parser')
+ tag = soup.find(id="resource")
+ track = json.loads(urllib.parse.unquote(tag.string))
+ track['preview_url'] = deejai.urls.get(track_id, '')
+ tag.string.replace_with(urllib.parse.quote(json.dumps(track)))
+ return b64encode(str(soup).encode('ascii'))
+
+
+@app.post('/api/v1/playlist_widget')
+async def make_playlist_widget(playlist_widget: schemas.PlaylistWidget):
+ """Make a new Spotify playlist widget.
+
+ Args:
+ playlist_widget (schemas.PlaylistWidget): List of track IDs.
+
+ Returns:
+ str: Base 64 encoded HTML which can be embedded in an iframe.
+ """
+
+ assert len(playlist_widget.track_ids) > 0
+ headers = {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/92.0.4515.159 Safari/537.36'
+ }
+ async with aiohttp.ClientSession() as session:
+ try:
+ async with session.get(
+ f'https://open.spotify.com/embed/track/{playlist_widget.track_ids[0]}',
+ headers=headers) as response:
+ if response.status != 200:
+ raise HTTPException(status_code=response.status,
+ detail=response.reason)
+ text = await response.text()
+ except aiohttp.ClientError as error:
+ raise HTTPException(status_code=400, detail=str(error)) from error
+ soup = BeautifulSoup(text, 'html.parser')
+ tag = soup.find(id="resource")
+ track = json.loads(urllib.parse.unquote(tag.string))
+ playlist = {
+ 'images': track['album']['images'],
+ 'tracks': {},
+ 'type': 'playlist',
+ 'uri': 'spotify:playlist:6itFIZoAKyetrbFr8BxSd2',
+ 'dominantColor': track['dominantColor']
+ }
+ playlist['tracks']['items'] = []
+ for track_id in playlist_widget.track_ids:
+ title = deejai.tracks[track_id]
+ track = {
+ 'is_local': True,
+ 'is_playable': True,
+ 'name': title[title.find(' - ') + 3:],
+ 'preview_url': deejai.urls.get(track_id, ''),
+ 'artists': [{
+ 'name': title[:title.find(' - ')]
+ }],
+ 'duration_ms': '30000',
+ 'uri': f'spotify:track{track_id}'
+ }
+ playlist['tracks']['items'].append({'track': track})
+ playlist['name'] = playlist['tracks']['items'][0]['track']['name']
+ playlist['owner'] = {
+ 'display_name':
+ playlist['tracks']['items'][0]['track']['artists'][0]['name']
+ }
+ tag.string.replace_with(urllib.parse.quote(json.dumps(playlist)))
+ return b64encode(str(soup).encode('ascii'))
@app.get('/api/v1/search')
diff --git a/backend/schemas.py b/backend/schemas.py
index b1ff20f..4c4529e 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -59,3 +59,8 @@ class Playlist(BaseModel): # pylint: disable=too-few-public-methods
creativity: Optional[float] = 0.5
noise: Optional[float] = 0
uploads: Optional[int] = 0
+
+class PlaylistWidget(BaseModel): # pylint: disable=too-few-public-methods
+ """Schema for generating a new playlist widget.
+ """
+ track_ids: list
diff --git a/src/components/ErrorBoundary.js b/src/components/ErrorBoundary.js
index a8f2ef8..2e6f9fa 100644
--- a/src/components/ErrorBoundary.js
+++ b/src/components/ErrorBoundary.js
@@ -21,7 +21,7 @@ export default class ErrorBoundary extends Component {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
- Whoopsie daisy, it looks like something has gone wrong.
+ Whoopsie daisy, it looks like something has gone wrong.
);
}
diff --git a/src/components/Playlist.native.js b/src/components/Playlist.native.js
new file mode 100644
index 0000000..a3dec10
--- /dev/null
+++ b/src/components/Playlist.native.js
@@ -0,0 +1,76 @@
+import React, { PureComponent, Suspense, useState } from 'react';
+import { WebView, View, Spinner } from './Platform';
+
+function createResource(pending) {
+ let error, response;
+ pending.then(r => response = r).catch(e => error = e);
+ return {
+ read() {
+ if (error) throw error;
+ if (response) return response;
+ throw pending;
+ }
+ };
+}
+
+// Avoid unncessary re-renders
+class SpotifyPlaylistWidget extends PureComponent {
+ render() {
+ const { resource, height } = this.props;
+ const iframe = (resource) ? resource.read() : null;
+
+ return (
+
+ );
+ }
+}
+
+export default function Playlist({ track_ids = [], waypoints = [] }) {
+ const height = 80 + 50 * track_ids.length;
+ const [ resource, _ ] = useState(createResource(new Promise(resolves => {
+ fetch(`${process.env.REACT_APP_API_URL}/playlist_widget`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ 'track_ids': track_ids
+ })
+ })
+ .then(response => (response.status === 200) ? response.text() : '')
+ .then(data => resolves({ data: data }))
+ .catch(error => console.error('Error:', error));
+ })));
+
+ return (
+
+
+
+ } >
+
+
+ );
+}
+
diff --git a/src/components/Track.js b/src/components/Track.js
index 9c71c23..dd4390a 100644
--- a/src/components/Track.js
+++ b/src/components/Track.js
@@ -1,7 +1,10 @@
-import { PureComponent, Suspense } from 'react';
-import Spinner from 'react-bootstrap/Spinner';
+import { PureComponent, Suspense, useState } from 'react';
+import { View, Spinner } from './Platform';
import VisibilitySensor from 'react-visibility-sensor';
-import './Track.css';
+
+try {
+ require('./Track.css');
+} catch (e) { }
function createResource(pending) {
let error, response;
@@ -50,18 +53,18 @@ export default function Track({
Track.cache = {};
}
- const resource = (track_id in Track.cache) ? Track.cache[track_id] :
+ const [ resource, _ ] = useState((track_id in Track.cache) ? Track.cache[track_id] :
createResource(new Promise(resolves => {
fetch(`${process.env.REACT_APP_API_URL}/widget?track_id=${track_id}`)
.then(response => (response.status === 200) ? response.text() : '')
.then(data => resolves({ data: data }))
.catch(error => console.error('Error:', error));
- }));
+ })));
Track.cache[track_id] = resource;
return (
-
+
} >