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 ( - + } >