From dc10a0f52b880f60ee1ae8a03e979c0ae3ac0f32 Mon Sep 17 00:00:00 2001 From: Ben Arc Date: Wed, 28 Apr 2021 12:04:47 +0100 Subject: [PATCH] Connects to spotify --- lnbits/extensions/jukebox/__init__.py | 1 - lnbits/extensions/jukebox/config.json | 2 +- lnbits/extensions/jukebox/crud.py | 55 +++- lnbits/extensions/jukebox/migrations.py | 17 +- lnbits/extensions/jukebox/models.py | 18 +- lnbits/extensions/jukebox/static/js/index.js | 304 +++++++----------- .../jukebox/templates/jukebox/_api_docs.html | 42 +-- .../jukebox/templates/jukebox/index.html | 251 +++++++++------ lnbits/extensions/jukebox/views_api.py | 58 ++-- 9 files changed, 395 insertions(+), 353 deletions(-) diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py index 785c9c579..b6ec402f7 100644 --- a/lnbits/extensions/jukebox/__init__.py +++ b/lnbits/extensions/jukebox/__init__.py @@ -8,6 +8,5 @@ jukebox_ext: Blueprint = Blueprint( "jukebox", __name__, static_folder="static", template_folder="templates" ) - from .views_api import * # noqa from .views import * # noqa diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json index 04c69cc11..def7c7a30 100644 --- a/lnbits/extensions/jukebox/config.json +++ b/lnbits/extensions/jukebox/config.json @@ -1,5 +1,5 @@ { - "name": "Jukebox", + "name": "SpotifyJukebox", "short_description": "Spotify jukebox middleware", "icon": "audiotrack", "contributors": ["benarc"] diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py index 98d48dc3f..99d1c4cd2 100644 --- a/lnbits/extensions/jukebox/crud.py +++ b/lnbits/extensions/jukebox/crud.py @@ -2,32 +2,69 @@ from typing import List, Optional from . import db from .models import Jukebox +from lnbits.helpers import urlsafe_short_hash -async def create_update_jukebox(wallet_id: str) -> int: +async def create_jukebox( + wallet: str, + title: str, + price: int, + sp_user: str, + sp_secret: str, + sp_token: Optional[str] = "", + sp_device: Optional[str] = "", + sp_playlists: Optional[str] = "", +) -> Jukebox: juke_id = urlsafe_short_hash() result = await db.execute( """ - INSERT INTO jukebox (id, wallet, user, secret, token, playlists) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO jukebox (id, title, wallet, sp_user, sp_secret, sp_token, sp_device, sp_playlists, price) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (juke_id, wallet_id, "", "", "", ""), + ( + juke_id, + title, + wallet, + sp_user, + sp_secret, + sp_token, + sp_device, + sp_playlists, + int(price), + ), ) - return result._result_proxy.lastrowid + jukebox = await get_jukebox(juke_id) + assert jukebox, "Newly created Jukebox couldn't be retrieved" + return jukebox + +async def update_jukebox(sp_user: str, **kwargs) -> Optional[Jukebox]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE jukebox SET {q} WHERE sp_user = ?", (*kwargs.values(), sp_user) + ) + row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (sp_user,)) + return Jukebox(**row) if row else None async def get_jukebox(id: str) -> Optional[Jukebox]: row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,)) - return Shop(**dict(row)) if row else None + return Jukebox(**row) if row else None + + +async def get_jukebox_by_user(user: str) -> Optional[Jukebox]: + row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (user,)) + return Jukebox(**row) if row else None + async def get_jukeboxs(id: str) -> Optional[Jukebox]: - row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,)) - return Shop(**dict(row)) if row else None + rows = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,)) + return [Jukebox(**row) for row in rows] + async def delete_jukebox(shop: int, item_id: int): await db.execute( """ DELETE FROM jukebox WHERE id = ? """, - (shop, item_id), + (Jukebox, item_id), ) diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py index 552293481..365f13e5a 100644 --- a/lnbits/extensions/jukebox/migrations.py +++ b/lnbits/extensions/jukebox/migrations.py @@ -5,12 +5,15 @@ async def m001_initial(db): await db.execute( """ CREATE TABLE jukebox ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wallet TEXT NOT NULL, - user TEXT NOT NULL, - secret TEXT NOT NULL, - token TEXT NOT NULL, - playlists TEXT NOT NULL + id TEXT PRIMARY KEY, + title TEXT, + wallet TEXT, + sp_user TEXT NOT NULL, + sp_secret TEXT NOT NULL, + sp_token TEXT, + sp_device TEXT, + sp_playlists TEXT, + price INTEGER ); """ - ) \ No newline at end of file + ) diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py index 020a699cf..9f3ec6299 100644 --- a/lnbits/extensions/jukebox/models.py +++ b/lnbits/extensions/jukebox/models.py @@ -6,14 +6,18 @@ from quart import url_for from typing import NamedTuple, Optional, List, Dict from sqlite3 import Row + class Jukebox(NamedTuple): - id: int + id: str + title: str wallet: str - user: str - secret: str - token: str - playlists: str + sp_user: str + sp_secret: str + sp_token: str + sp_device: str + sp_playlists: str + price: int @classmethod - def from_row(cls, row: Row) -> "Charges": - return cls(**dict(row)) \ No newline at end of file + def from_row(cls, row: Row) -> "Jukebox": + return cls(**dict(row)) diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js index 699f505bd..074980537 100644 --- a/lnbits/extensions/jukebox/static/js/index.js +++ b/lnbits/extensions/jukebox/static/js/index.js @@ -4,28 +4,25 @@ Vue.component(VueQrcode.name, VueQrcode) const pica = window.pica() -const defaultItemData = { - unit: 'sat' -} + new Vue({ el: '#vue', mixins: [windowMixin], data() { return { - selectedWallet: null, - confirmationMethod: 'wordlist', - wordlistTainted: false, - jukebox: { - method: null, - wordlist: [], - items: [] - }, - itemDialog: { + isPwd: true, + tokenFetched: true, + device: [], + jukebox: {}, + playlists: [], + step: 1, + locationcbPath: "", + jukeboxDialog: { show: false, - data: {...defaultItemData}, - units: ['sat'] - } + data: {} + }, + spotifyDialog: false } }, computed: { @@ -34,183 +31,130 @@ new Vue({ } }, methods: { - openNewDialog() { - this.itemDialog.show = true - this.itemDialog.data = {...defaultItemData} + closeFormDialog() { + this.jukeboxDialog.data = {} + this.jukeboxDialog.show = false + this.step = 1 }, - openUpdateDialog(itemId) { - this.itemDialog.show = true - let item = this.jukebox.items.find(item => item.id === itemId) - this.itemDialog.data = item - }, - imageAdded(file) { - let blobURL = URL.createObjectURL(file) - let image = new Image() - image.src = blobURL - image.onload = async () => { - let canvas = document.createElement('canvas') - canvas.setAttribute('width', 100) - canvas.setAttribute('height', 100) - await pica.resize(image, canvas, { - quality: 0, - alpha: true, - unsharpAmount: 95, - unsharpRadius: 0.9, - unsharpThreshold: 70 + submitSpotify() { + + self = this + console.log(self.jukeboxDialog.data) + self.requestAuthorization() + this.$q.notify({ + spinner: true, + message: 'Fetching token', + timeout: 4000 }) - this.itemDialog.data.image = canvas.toDataURL() - this.itemDialog = {...this.itemDialog} - } - }, - imageCleared() { - this.itemDialog.data.image = null - this.itemDialog = {...this.itemDialog} - }, - disabledAddItemButton() { - return ( - !this.itemDialog.data.name || - this.itemDialog.data.name.length === 0 || - !this.itemDialog.data.price || - !this.itemDialog.data.description || - !this.itemDialog.data.unit || - this.itemDialog.data.unit.length === 0 - ) - }, - changedWallet(wallet) { - this.selectedWallet = wallet - this.loadShop() - }, - loadShop() { - LNbits.api - .request('GET', '/jukebox/api/v1/jukebox', this.selectedWallet.inkey) - .then(response => { - this.jukebox = response.data - this.confirmationMethod = response.data.method - this.wordlistTainted = false - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - async setMethod() { - try { - await LNbits.api.request( - 'PUT', - '/jukebox/api/v1/jukebox/method', - this.selectedWallet.inkey, - {method: this.confirmationMethod, wordlist: this.jukebox.wordlist} - ) - } catch (err) { - LNbits.utils.notifyApiError(err) - return - } - - this.$q.notify({ - message: - `Method set to ${this.confirmationMethod}.` + - (this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''), - timeout: 700 - }) - this.loadShop() - }, - async sendItem() { - let {id, name, image, description, price, unit} = this.itemDialog.data - const data = { - name, - description, - image, - price, - unit - } - - try { - if (id) { - await LNbits.api.request( - 'PUT', - '/jukebox/api/v1/jukebox/items/' + id, - this.selectedWallet.inkey, - data - ) - } else { - await LNbits.api.request( - 'POST', - '/jukebox/api/v1/jukebox/items', - this.selectedWallet.inkey, - data - ) - this.$q.notify({ - message: `Item '${this.itemDialog.data.name}' added.`, - timeout: 700 - }) - } - } catch (err) { - LNbits.utils.notifyApiError(err) - return - } - - this.loadShop() - this.itemDialog.show = false - this.itemDialog.data = {...defaultItemData} - }, - toggleItem(itemId) { - let item = this.jukebox.items.find(item => item.id === itemId) - item.enabled = !item.enabled - - LNbits.api - .request( - 'PUT', - '/jukebox/api/v1/jukebox/items/' + itemId, - this.selectedWallet.inkey, - item + LNbits.api.request( + 'POST', + '/jukebox/api/v1/jukebox/', + self.g.user.wallets[0].adminkey, + self.jukeboxDialog.data ) .then(response => { - this.$q.notify({ - message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`, - timeout: 700 - }) - this.jukebox.items = this.jukebox.items - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - deleteItem(itemId) { - LNbits.utils - .confirmDialog('Are you sure you want to delete this item?') - .onOk(() => { - LNbits.api - .request( - 'DELETE', - '/jukebox/api/v1/jukebox/items/' + itemId, - this.selectedWallet.inkey - ) + if(response.data){ + var timerId = setInterval(function(){ + if(!self.jukeboxDialog.data.sp_user){ + clearInterval(timerId); + } + LNbits.api + .request('GET', '/jukebox/api/v1/jukebox/spotify/' + self.jukeboxDialog.data.sp_user, self.g.user.wallets[0].inkey) .then(response => { - this.$q.notify({ - message: `Item deleted.`, - timeout: 700 + if(response.data.sp_token){ + console.log(response.data.sp_token) + + self.step = 3 + clearInterval(timerId); + self.refreshPlaylists() + self.$q.notify({ + message: 'Success! App is now linked!', + timeout: 3000 }) - this.jukebox.items.splice( - this.jukebox.items.findIndex(item => item.id === itemId), - 1 - ) + //set devices, playlists + } }) .catch(err => { - LNbits.utils.notifyApiError(err) + LNbits.utils.notifyApiError(err) }) + }, 3000) + } }) - } + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + requestAuthorization(){ + self = this + let url = 'https://accounts.spotify.com/authorize' + url += '?scope=user-modify-playback-state%20user-read-playback-position' + url += '%20user-library-read%20streaming%20user-read-playback-state' + url += '%20user-read-recently-played%20playlist-read-private&response_type=code' + url += '&redirect_uri=' + encodeURIComponent(self.locationcbPath) + self.jukeboxDialog.data.sp_user + url += '&client_id=' + self.jukeboxDialog.data.sp_user + url += '&show_dialog=true' + console.log(url) + window.open(url) + }, + openNewDialog() { + this.jukeboxDialog.show = true + this.jukeboxDialog.data = {} + }, + openUpdateDialog(itemId) { + this.jukeboxDialog.show = true + let item = this.jukebox.items.find(item => item.id === itemId) + this.jukeboxDialog.data = item + }, + + callApi(method, url, body, callback){ + let xhr = new XMLHttpRequest() + xhr.open(method, url, true) + xhr.setRequestHeader('Content-Type', 'application/json') + xhr.setRequestHeader('Authorization', 'Bearer ' + self.jukeboxDialog.data.sp_token) + xhr.send(body) + xhr.onload = callback + }, + refreshPlaylists(){ + console.log("sdfvasdv") + callApi( "GET", "https://api.spotify.com/v1/me/playlists", null, handlePlaylistsResponse ) + }, + handlePlaylistsResponse(){ + console.log("data") + if ( this.status == 200 ){ + var data = JSON.parse(this.responseText) + console.log(data) + } + else if ( this.status == 401 ){ + refreshAccessToken() + } + else { + console.log(this.responseText) + alert(this.responseText) + } + }, + refreshAccessToken(){ + refresh_token = localStorage.getItem("refresh_token") + let body = "grant_type=refresh_token" + body += "&refresh_token=" + self.jukeboxDialog.data.sp_token + body += "&client_id=" + self.jukeboxDialog.data.sp_user + callAuthorizationApi(body) + }, + callAuthorizationApi(body){ + let xhr = new XMLHttpRequest() + xhr.open("POST", TOKEN, true) + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') + xhr.setRequestHeader('Authorization', 'Basic ' + btoa(self.jukeboxDialog.data.sp_user + ":" + self.jukeboxDialog.data.sp_secret)) + xhr.send(body) + xhr.onload = handleAuthorizationResponse + }, }, created() { this.selectedWallet = this.g.user.wallets[0] - this.loadShop() - - LNbits.api - .request('GET', '/jukebox/api/v1/currencies') - .then(response => { - this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]} - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) + this.locationcbPath = String([ + window.location.protocol, + '//', + window.location.host, + '/jukebox/api/v1/jukebox/spotify/cb/' + ].join('')) } }) diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html index 7d15aa8f6..f705aeadd 100644 --- a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html +++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html @@ -1,42 +1,20 @@ -
    -
  1. Register items.
  2. -
  3. - Print QR codes and paste them on your store, your menu, somewhere, - somehow. -
  4. -
  5. - Clients scan the QR codes and get information about the items plus the - price on their phones directly (they must have internet) -
  6. -
  7. - Once they decide to pay, they'll get an invoice on their phones - automatically -
  8. -
  9. - When the payment is confirmed, a confirmation code will be issued for - them. -
  10. -
-

- The confirmation codes are words from a predefined sequential word list. - Each new payment bumps the words sequence by 1. So you can check the - confirmation codes manually by just looking at them. -

-

- For example, if your wordlist is - [apple, banana, coconut] the first purchase will be - apple, the second banana and so on. When it - gets to the end it starts from the beginning again. -

-

Powered by LNURL-pay.

+ To use this extension you need a Spotify client ID and client secret. You + get these by creating an app in the Spotify developers dashboard + here +

Select the playlists you want people to be able to pay for, + share the frontend page, profit :)

+ Made by, benarc. Inspired by, + pirosb3.
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html index 48a83d514..ab0569318 100644 --- a/lnbits/extensions/jukebox/templates/jukebox/index.html +++ b/lnbits/extensions/jukebox/templates/jukebox/index.html @@ -4,15 +4,15 @@
+ Add Spotify Jukebox
-
-
Items
-
-
- Add jukebox -
+
Items
{% raw %}
- - - -
-
Adding a new item
- - - - - -
- Copy LNURL -
- + + + + - - - - - - - -
-
+ + +
+
Continue - {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} - Item - + Continue
-
- + Cancel
- - + +
+ + + + To use this extension you need a Spotify client ID and client secret. + You get these by creating an app in the Spotify developers dashboard + here.
+ In the app go to edit-settings, set the redirect URI to this link + (replacing the CLIENT-ID with your own) {% raw %}{{ locationcbPath + }}CLIENT-ID{% endraw %} + + + + + + +
+
+ Get token + Get token +
+
+ Cancel +
+
+ +
+
+ + + + +
+
+ Create Jukebox +
+
+ Cancel +
+
+
+
diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py index 5433ddded..fa7194272 100644 --- a/lnbits/extensions/jukebox/views_api.py +++ b/lnbits/extensions/jukebox/views_api.py @@ -1,13 +1,15 @@ -from quart import g, jsonify +from quart import g, jsonify, request from http import HTTPStatus from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore from lnbits.decorators import api_check_wallet_key, api_validate_post_request - +import httpx from . import jukebox_ext from .crud import ( - create_update_jukebox, + create_jukebox, + update_jukebox, get_jukebox, + get_jukebox_by_user, get_jukeboxs, delete_jukebox, ) @@ -17,33 +19,45 @@ from .models import Jukebox @jukebox_ext.route("/api/v1/jukebox", methods=["GET"]) @api_check_wallet_key("invoice") async def api_get_jukeboxs(): - jukebox = await get_jukeboxs(g.wallet.id) - return ( - jsonify( - { - jukebox._asdict() - } - ), - HTTPStatus.OK, + jsonify([jukebox._asdict() for jukebox in await get_jukeboxs(g.wallet.id)]), + + +##################SPOTIFY AUTH##################### + + +@jukebox_ext.route("/api/v1/jukebox/spotify/cb//", methods=["GET"]) +async def api_check_credentials_callbac(sp_user): + jukebox = await get_jukebox_by_user(sp_user) + jukebox = await update_jukebox( + sp_user=sp_user, sp_secret=jukebox.sp_secret, sp_token=request.args.get('code') ) + return "

Success!

You can close this window

" -#websocket get spotify crap +@jukebox_ext.route("/api/v1/jukebox/spotify/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_check_credentials_check(sp_user): + jukebox = await get_jukebox_by_user(sp_user) + return jsonify(jukebox._asdict()), HTTPStatus.CREATED -@jukebox_ext.route("/api/v1/jukebox/items", methods=["POST"]) -@jukebox_ext.route("/api/v1/jukebox/items/", methods=["PUT"]) + +@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"]) +@jukebox_ext.route("/api/v1/jukebox/", methods=["PUT"]) @api_check_wallet_key("admin") @api_validate_post_request( - schema={ - "wallet": {"type": "string", "empty": False}, - "user": {"type": "string", "empty": False}, - "secret": {"type": "string", "required": False}, - "token": {"type": "string", "required": True}, - "playlists": {"type": "string", "required": True}, + "title": {"type": "string", "empty": False, "required": True}, + "wallet": {"type": "string", "empty": False, "required": True}, + "sp_user": {"type": "string", "empty": False, "required": True}, + "sp_secret": {"type": "string", "required": True}, + "sp_token": {"type": "string", "required": False}, + "sp_device": {"type": "string", "required": False}, + "sp_playlists": {"type": "string", "required": False}, + "price": {"type": "string", "required": True}, } ) async def api_create_update_jukebox(item_id=None): - jukebox = await create_update_jukebox(g.wallet.id, **g.data) + print(g.data) + jukebox = await create_jukebox(**g.data) return jsonify(jukebox._asdict()), HTTPStatus.CREATED @@ -51,4 +65,4 @@ async def api_create_update_jukebox(item_id=None): @api_check_wallet_key("admin") async def api_delete_item(juke_id): shop = await delete_jukebox(juke_id) - return "", HTTPStatus.NO_CONTENT \ No newline at end of file + return "", HTTPStatus.NO_CONTENT